diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 0f275dcd..ce11c052 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -93,7 +93,7 @@ jobs: api-level: 24 arch: x86_64 profile: Nexus 6 - script: flutter test integration_test/token_refresh_test.dart integration_test/string_collector_test.dart integration_test/fill_in_the_blank_test.dart integration_test/team_game_test.dart integration_test/edit_specification_test.dart --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json + script: flutter test integration_test/token_refresh_test.dart integration_test/string_collector_test.dart integration_test/fill_in_the_blank_test.dart integration_test/team_game_test.dart integration_test/edit_specification_test.dart integration_test/validation_test.dart --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json - name: Publish test results uses: EnricoMi/publish-unit-test-result-action@v2 diff --git a/registrations/lib/registrations/accounts.ex b/registrations/lib/registrations/accounts.ex new file mode 100644 index 00000000..ae39a1f1 --- /dev/null +++ b/registrations/lib/registrations/accounts.ex @@ -0,0 +1,65 @@ +defmodule Registrations.Accounts do + @moduledoc false + import Ecto.Query, warn: false + + alias Registrations.Repo + alias Registrations.UserRole + + def list_user_roles(user) do + from(r in UserRole, where: r.user_id == ^user.id) + |> Repo.all() + |> Repo.preload([:user, :assigned_by]) + end + + def list_all_user_roles(filters \\ %{}) do + UserRole + |> filter_roles_query(filters) + |> Repo.all() + |> Repo.preload([:user, :assigned_by]) + end + + defp filter_roles_query(query, filters) do + Enum.reduce(filters, query, fn + {"role", role}, query when is_binary(role) -> + from(r in query, where: r.role == ^role) + + _, query -> + query + end) + end + + def get_user_role!(id) do + UserRole + |> Repo.get!(id) + |> Repo.preload([:user, :assigned_by]) + end + + def assign_role(user_id, role, assigned_by_id \\ nil) do + %UserRole{} + |> UserRole.changeset(%{user_id: user_id, role: role, assigned_by_id: assigned_by_id}) + |> Repo.insert() + |> case do + {:ok, user_role} -> {:ok, Repo.preload(user_role, [:user, :assigned_by])} + error -> error + end + end + + def remove_role(id) do + UserRole + |> Repo.get!(id) + |> Repo.delete() + end + + def has_role?(user, role) do + Repo.exists?(from(r in UserRole, where: r.user_id == ^user.id and r.role == ^role)) + end + + def list_users_with_role(role) do + from(r in UserRole, + where: r.role == ^role, + preload: [:user] + ) + |> Repo.all() + |> Enum.map(& &1.user) + end +end diff --git a/registrations/lib/registrations/user_role.ex b/registrations/lib/registrations/user_role.ex new file mode 100644 index 00000000..1724c070 --- /dev/null +++ b/registrations/lib/registrations/user_role.ex @@ -0,0 +1,30 @@ +defmodule Registrations.UserRole do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + + @valid_roles ~w(validator validation_overseer) + + schema "user_roles" do + field(:role, :string) + + belongs_to(:user, RegistrationsWeb.User, type: :binary_id) + belongs_to(:assigned_by, RegistrationsWeb.User, type: :binary_id, foreign_key: :assigned_by_id) + + timestamps() + end + + @doc false + def changeset(user_role, attrs) do + user_role + |> cast(attrs, [:user_id, :role, :assigned_by_id]) + |> validate_required([:user_id, :role]) + |> validate_inclusion(:role, @valid_roles) + |> unique_constraint([:user_id, :role]) + |> assoc_constraint(:user) + end + + def valid_roles, do: @valid_roles +end diff --git a/registrations/lib/registrations/waydowntown.ex b/registrations/lib/registrations/waydowntown.ex index 49debccb..2dbf0342 100644 --- a/registrations/lib/registrations/waydowntown.ex +++ b/registrations/lib/registrations/waydowntown.ex @@ -9,7 +9,9 @@ defmodule Registrations.Waydowntown do alias Registrations.Waydowntown.Reveal alias Registrations.Waydowntown.Run alias Registrations.Waydowntown.Specification + alias Registrations.Waydowntown.SpecificationValidation alias Registrations.Waydowntown.Submission + alias Registrations.Waydowntown.ValidationComment alias RegistrationsWeb.User def update_user(user, attrs) do @@ -904,4 +906,107 @@ defmodule Registrations.Waydowntown do |> Repo.get!(id) |> Repo.preload([:answer, :user]) end + + # Specification Validations + + defp validation_preloads do + user_query = user_preload_query() + + [ + specification: [answers: [:region], region: [parent: [parent: [:parent]]]], + validation_comments: [:answer], + validator: user_query, + assigned_by: user_query, + run: [] + ] + end + + def create_specification_validation(attrs) do + %SpecificationValidation{} + |> SpecificationValidation.changeset(attrs) + |> Repo.insert() + |> case do + {:ok, validation} -> {:ok, Repo.preload(validation, validation_preloads())} + error -> error + end + end + + def get_specification_validation!(id) do + SpecificationValidation + |> Repo.get!(id) + |> Repo.preload(validation_preloads()) + end + + def update_specification_validation(%SpecificationValidation{} = validation, attrs, role) do + validation + |> SpecificationValidation.changeset(attrs) + |> SpecificationValidation.validate_status_transition(validation.status, role) + |> Repo.update() + |> case do + {:ok, validation} -> {:ok, Repo.preload(validation, validation_preloads())} + error -> error + end + end + + def list_validations_for_validator(user) do + from(v in SpecificationValidation, where: v.validator_id == ^user.id, order_by: [desc: v.inserted_at]) + |> Repo.all() + |> Repo.preload(validation_preloads()) + end + + def list_validations_for_overseer(user) do + from(v in SpecificationValidation, where: v.assigned_by_id == ^user.id, order_by: [desc: v.inserted_at]) + |> Repo.all() + |> Repo.preload(validation_preloads()) + end + + def list_validations_for_specification(specification_id) do + from(v in SpecificationValidation, where: v.specification_id == ^specification_id, order_by: [desc: v.inserted_at]) + |> Repo.all() + |> Repo.preload(validation_preloads()) + end + + def list_specification_validations do + SpecificationValidation + |> Repo.all() + |> Repo.preload(validation_preloads()) + end + + # Validation Comments + + def create_validation_comment(attrs) do + %ValidationComment{} + |> ValidationComment.changeset(attrs) + |> Repo.insert() + |> case do + {:ok, comment} -> {:ok, Repo.preload(comment, [:answer, :specification_validation])} + error -> error + end + end + + def get_validation_comment!(id) do + ValidationComment + |> Repo.get!(id) + |> Repo.preload([:answer, :specification_validation]) + end + + def update_validation_comment(%ValidationComment{} = comment, attrs) do + comment + |> ValidationComment.changeset(attrs) + |> Repo.update() + |> case do + {:ok, comment} -> {:ok, Repo.preload(comment, [:answer, :specification_validation])} + error -> error + end + end + + def delete_validation_comment(%ValidationComment{} = comment) do + Repo.delete(comment) + end + + def list_validation_comments(validation_id) do + from(c in ValidationComment, where: c.specification_validation_id == ^validation_id) + |> Repo.all() + |> Repo.preload([:answer, :specification_validation]) + end end diff --git a/registrations/lib/registrations/waydowntown/specification_validation.ex b/registrations/lib/registrations/waydowntown/specification_validation.ex new file mode 100644 index 00000000..c7d01909 --- /dev/null +++ b/registrations/lib/registrations/waydowntown/specification_validation.ex @@ -0,0 +1,67 @@ +defmodule Registrations.Waydowntown.SpecificationValidation do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @schema_prefix "waydowntown" + + @valid_statuses ~w(assigned in_progress submitted accepted rejected) + @validator_transitions %{ + "assigned" => ["in_progress"], + "in_progress" => ["submitted"] + } + @overseer_transitions %{ + "submitted" => ["accepted", "rejected"] + } + + schema "specification_validations" do + field(:status, :string, default: "assigned") + field(:play_mode, :string) + field(:overall_notes, :string) + + belongs_to(:specification, Registrations.Waydowntown.Specification, type: :binary_id) + belongs_to(:validator, RegistrationsWeb.User, type: :binary_id, foreign_key: :validator_id) + belongs_to(:assigned_by, RegistrationsWeb.User, type: :binary_id, foreign_key: :assigned_by_id) + belongs_to(:run, Registrations.Waydowntown.Run, type: :binary_id) + + has_many(:validation_comments, Registrations.Waydowntown.ValidationComment, on_delete: :delete_all) + + timestamps() + end + + @doc false + def changeset(validation, attrs) do + validation + |> cast(attrs, [:specification_id, :validator_id, :assigned_by_id, :status, :play_mode, :overall_notes, :run_id]) + |> validate_required([:specification_id, :validator_id, :assigned_by_id]) + |> validate_inclusion(:status, @valid_statuses) + |> validate_inclusion(:play_mode, ~w(blind with_answers), message: "must be 'blind' or 'with_answers'") + |> unique_constraint([:specification_id, :validator_id]) + |> assoc_constraint(:specification) + end + + def validate_status_transition(changeset, current_status, role) do + new_status = get_change(changeset, :status) + + if new_status do + transitions = + case role do + :validator -> @validator_transitions + :overseer -> @overseer_transitions + end + + allowed = Map.get(transitions, current_status, []) + + if new_status in allowed do + changeset + else + add_error(changeset, :status, "cannot transition from '#{current_status}' to '#{new_status}'") + end + else + changeset + end + end + + def valid_statuses, do: @valid_statuses +end diff --git a/registrations/lib/registrations/waydowntown/validation_comment.ex b/registrations/lib/registrations/waydowntown/validation_comment.ex new file mode 100644 index 00000000..41f1c44d --- /dev/null +++ b/registrations/lib/registrations/waydowntown/validation_comment.ex @@ -0,0 +1,42 @@ +defmodule Registrations.Waydowntown.ValidationComment do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @schema_prefix "waydowntown" + + @valid_fields ~w(answer label hint) + + schema "validation_comments" do + field(:field, :string) + field(:comment, :string) + field(:suggested_value, :string) + + belongs_to(:specification_validation, Registrations.Waydowntown.SpecificationValidation, type: :binary_id) + belongs_to(:answer, Registrations.Waydowntown.Answer, type: :binary_id) + + timestamps() + end + + @doc false + def changeset(comment, attrs) do + comment + |> cast(attrs, [:specification_validation_id, :answer_id, :field, :comment, :suggested_value]) + |> validate_required([:specification_validation_id]) + |> validate_inclusion(:field, @valid_fields) + |> validate_has_content() + |> assoc_constraint(:specification_validation) + end + + defp validate_has_content(changeset) do + comment = get_field(changeset, :comment) + suggested_value = get_field(changeset, :suggested_value) + + if is_nil(comment) and is_nil(suggested_value) do + add_error(changeset, :comment, "at least one of comment or suggested_value is required") + else + changeset + end + end +end diff --git a/registrations/lib/registrations_web/controllers/session_controller.ex b/registrations/lib/registrations_web/controllers/session_controller.ex index bb4d5ccd..925a8775 100644 --- a/registrations/lib/registrations_web/controllers/session_controller.ex +++ b/registrations/lib/registrations_web/controllers/session_controller.ex @@ -7,6 +7,9 @@ defmodule RegistrationsWeb.SessionController do # FIXME can this be tested? It uses HTTPOnly cookie def show(conn, params) do + user = conn.assigns[:current_user] + user = Registrations.Repo.preload(user, :user_roles) + conn = assign(conn, :current_user, user) render(conn, "show.json", %{conn: conn, params: params}) end diff --git a/registrations/lib/registrations_web/controllers/specification_validation_controller.ex b/registrations/lib/registrations_web/controllers/specification_validation_controller.ex new file mode 100644 index 00000000..b0069fbc --- /dev/null +++ b/registrations/lib/registrations_web/controllers/specification_validation_controller.ex @@ -0,0 +1,106 @@ +defmodule RegistrationsWeb.SpecificationValidationController do + use RegistrationsWeb, :controller + + alias Registrations.Accounts + alias Registrations.Waydowntown + + action_fallback(RegistrationsWeb.FallbackController) + + def create(conn, params) do + current_user = Pow.Plug.current_user(conn) + + unless Accounts.has_role?(current_user, "validation_overseer") do + conn + |> put_status(:forbidden) + |> json(%{errors: [%{detail: "Must be a validation overseer"}]}) + else + validator_id = params["validator_id"] + + unless validator_id && Accounts.has_role?(%{id: validator_id}, "validator") do + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: [%{detail: "Target user must have the validator role"}]}) + else + attrs = + params + |> Map.put("assigned_by_id", current_user.id) + + case Waydowntown.create_specification_validation(attrs) do + {:ok, validation} -> + conn + |> put_status(:created) + |> render("show.json", %{data: validation, conn: conn, params: params}) + + {:error, changeset} -> + {:error, changeset} + end + end + end + end + + def index(conn, %{"specification_id" => specification_id} = params) do + validations = Waydowntown.list_validations_for_specification(specification_id) + render(conn, "index.json", %{data: validations, conn: conn, params: params}) + end + + def index(conn, params) do + validations = Waydowntown.list_specification_validations() + render(conn, "index.json", %{data: validations, conn: conn, params: params}) + end + + def mine(conn, params) do + current_user = Pow.Plug.current_user(conn) + validations = Waydowntown.list_validations_for_validator(current_user) + render(conn, "index.json", %{data: validations, conn: conn, params: params}) + end + + def oversee(conn, params) do + current_user = Pow.Plug.current_user(conn) + validations = Waydowntown.list_validations_for_overseer(current_user) + render(conn, "index.json", %{data: validations, conn: conn, params: params}) + end + + def show(conn, %{"id" => id} = params) do + current_user = Pow.Plug.current_user(conn) + validation = Waydowntown.get_specification_validation!(id) + + if validation.validator_id == current_user.id or validation.assigned_by_id == current_user.id or + current_user.admin do + render(conn, "show.json", %{data: validation, conn: conn, params: params}) + else + conn + |> put_status(:forbidden) + |> json(%{errors: [%{detail: "Not authorized to view this validation"}]}) + end + end + + def update(conn, %{"id" => id} = params) do + current_user = Pow.Plug.current_user(conn) + validation = Waydowntown.get_specification_validation!(id) + + cond do + validation.validator_id == current_user.id -> + case Waydowntown.update_specification_validation(validation, params, :validator) do + {:ok, updated} -> + render(conn, "show.json", %{data: updated, conn: conn, params: params}) + + {:error, changeset} -> + {:error, changeset} + end + + validation.assigned_by_id == current_user.id -> + case Waydowntown.update_specification_validation(validation, params, :overseer) do + {:ok, updated} -> + render(conn, "show.json", %{data: updated, conn: conn, params: params}) + + {:error, changeset} -> + {:error, changeset} + end + + true -> + conn + |> put_status(:forbidden) + |> json(%{errors: [%{detail: "Not authorized to update this validation"}]}) + end + end +end diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex index 21c529f8..81cb6749 100644 --- a/registrations/lib/registrations_web/controllers/test_controller.ex +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -6,6 +6,7 @@ defmodule RegistrationsWeb.TestController do use RegistrationsWeb, :controller alias Registrations.Repo + alias Registrations.UserRole alias Registrations.Waydowntown.Answer alias Registrations.Waydowntown.Region alias Registrations.Waydowntown.Specification @@ -15,7 +16,9 @@ defmodule RegistrationsWeb.TestController do def reset(conn, params) do # Truncate all waydowntown tables - CASCADE handles foreign key dependencies - Repo.query!("TRUNCATE waydowntown.reveals, waydowntown.submissions, waydowntown.participations, waydowntown.runs, waydowntown.answers, waydowntown.specifications, waydowntown.regions CASCADE") + Repo.query!("TRUNCATE waydowntown.validation_comments, waydowntown.specification_validations, waydowntown.reveals, waydowntown.submissions, waydowntown.participations, waydowntown.runs, waydowntown.answers, waydowntown.specifications, waydowntown.regions CASCADE") + # Truncate public-schema role tables + Repo.query!("TRUNCATE user_roles CASCADE") response = if params["create_user"] == "true" do @@ -45,6 +48,10 @@ defmodule RegistrationsWeb.TestController do game_data = create_string_collector_team_game(user) Map.merge(base_response, game_data) + "validation" -> + game_data = create_validation_game(user) + Map.merge(base_response, game_data) + _ -> base_response end @@ -166,6 +173,56 @@ defmodule RegistrationsWeb.TestController do } end + defp create_validation_game(user) do + region = Repo.insert!(%Region{name: "Test Region"}) + + specification = + Repo.insert!(%Specification{ + concept: "string_collector", + task_description: "Find all the hidden words", + start_description: "Look around for words", + region: region, + duration: 300, + creator_id: user.id + }) + + answer1 = Repo.insert!(%Answer{answer: "apple", specification_id: specification.id}) + answer2 = Repo.insert!(%Answer{answer: "banana", specification_id: specification.id}) + _answer3 = Repo.insert!(%Answer{answer: "cherry", specification_id: specification.id}) + + # Create overseer user with validation_overseer role + overseer = create_or_reset_user("overseer@example.com", @test_password, "Overseer") + Repo.insert!(%UserRole{user_id: overseer.id, role: "validation_overseer", assigned_by_id: user.id}) + + # Create validator user with validator role + validator = create_or_reset_user("validator@example.com", @test_password, "Validator") + Repo.insert!(%UserRole{user_id: validator.id, role: "validator", assigned_by_id: overseer.id}) + + # Give the main test user admin role for role management testing + Repo.query!("UPDATE users SET admin = true WHERE id = '#{user.id}'") + + # Create a specification validation assignment + validation = + Repo.insert!(%Registrations.Waydowntown.SpecificationValidation{ + specification_id: specification.id, + validator_id: validator.id, + assigned_by_id: overseer.id, + status: "assigned" + }) + + %{ + specification_id: specification.id, + answer_ids: [answer1.id, answer2.id], + validation_id: validation.id, + overseer_email: "overseer@example.com", + overseer_password: @test_password, + overseer_id: overseer.id, + validator_email: "validator@example.com", + validator_password: @test_password, + validator_id: validator.id + } + end + defp create_or_reset_test_user do create_or_reset_user(@test_email, @test_password, "Test User") end diff --git a/registrations/lib/registrations_web/controllers/user_role_controller.ex b/registrations/lib/registrations_web/controllers/user_role_controller.ex new file mode 100644 index 00000000..10da6b14 --- /dev/null +++ b/registrations/lib/registrations_web/controllers/user_role_controller.ex @@ -0,0 +1,33 @@ +defmodule RegistrationsWeb.UserRoleController do + use RegistrationsWeb, :controller + + alias Registrations.Accounts + + action_fallback(RegistrationsWeb.FallbackController) + + def index(conn, params) do + user_roles = Accounts.list_all_user_roles(params) + render(conn, "index.json", %{data: user_roles, conn: conn, params: params}) + end + + def create(conn, params) do + current_user = Pow.Plug.current_user(conn) + user_id = params["user_id"] + role = params["role"] + + case Accounts.assign_role(user_id, role, current_user.id) do + {:ok, user_role} -> + conn + |> put_status(:created) + |> render("show.json", %{data: user_role, conn: conn, params: params}) + + {:error, changeset} -> + {:error, changeset} + end + end + + def delete(conn, %{"id" => id}) do + Accounts.remove_role(id) + send_resp(conn, :no_content, "") + end +end diff --git a/registrations/lib/registrations_web/controllers/validation_comment_controller.ex b/registrations/lib/registrations_web/controllers/validation_comment_controller.ex new file mode 100644 index 00000000..175b85cd --- /dev/null +++ b/registrations/lib/registrations_web/controllers/validation_comment_controller.ex @@ -0,0 +1,63 @@ +defmodule RegistrationsWeb.ValidationCommentController do + use RegistrationsWeb, :controller + + alias Registrations.Waydowntown + + action_fallback(RegistrationsWeb.FallbackController) + + def create(conn, params) do + current_user = Pow.Plug.current_user(conn) + validation = Waydowntown.get_specification_validation!(params["specification_validation_id"]) + + if validation.validator_id == current_user.id do + case Waydowntown.create_validation_comment(params) do + {:ok, comment} -> + conn + |> put_status(:created) + |> render("show.json", %{data: comment, conn: conn, params: params}) + + {:error, changeset} -> + {:error, changeset} + end + else + conn + |> put_status(:forbidden) + |> json(%{errors: [%{detail: "Not authorized to comment on this validation"}]}) + end + end + + def update(conn, %{"id" => id} = params) do + current_user = Pow.Plug.current_user(conn) + comment = Waydowntown.get_validation_comment!(id) + validation = Waydowntown.get_specification_validation!(comment.specification_validation_id) + + if validation.validator_id == current_user.id do + case Waydowntown.update_validation_comment(comment, params) do + {:ok, updated} -> + render(conn, "show.json", %{data: updated, conn: conn, params: params}) + + {:error, changeset} -> + {:error, changeset} + end + else + conn + |> put_status(:forbidden) + |> json(%{errors: [%{detail: "Not authorized to update this comment"}]}) + end + end + + def delete(conn, %{"id" => id}) do + current_user = Pow.Plug.current_user(conn) + comment = Waydowntown.get_validation_comment!(id) + validation = Waydowntown.get_specification_validation!(comment.specification_validation_id) + + if validation.validator_id == current_user.id do + Waydowntown.delete_validation_comment(comment) + send_resp(conn, :no_content, "") + else + conn + |> put_status(:forbidden) + |> json(%{errors: [%{detail: "Not authorized to delete this comment"}]}) + end + end +end diff --git a/registrations/lib/registrations_web/models/user.ex b/registrations/lib/registrations_web/models/user.ex index 4f0a043b..4fce3a92 100644 --- a/registrations/lib/registrations_web/models/user.ex +++ b/registrations/lib/registrations_web/models/user.ex @@ -36,6 +36,8 @@ defmodule RegistrationsWeb.User do field(:admin, :boolean) + has_many(:user_roles, Registrations.UserRole, on_delete: :delete_all) + field(:attending, :boolean) belongs_to(:team, RegistrationsWeb.Team, type: :binary_id) diff --git a/registrations/lib/registrations_web/plugs/require_role.ex b/registrations/lib/registrations_web/plugs/require_role.ex new file mode 100644 index 00000000..137d3c73 --- /dev/null +++ b/registrations/lib/registrations_web/plugs/require_role.ex @@ -0,0 +1,20 @@ +defmodule RegistrationsWeb.Plugs.RequireRole do + @moduledoc false + import Plug.Conn + + def init(opts), do: opts + + def call(conn, opts) do + role = Keyword.fetch!(opts, :role) + user = conn.assigns[:current_user] + + if user && Registrations.Accounts.has_role?(user, role) do + conn + else + conn + |> put_status(:forbidden) + |> Phoenix.Controller.json(%{errors: [%{status: 403, title: "Forbidden", detail: "Role '#{role}' required"}]}) + |> halt() + end + end +end diff --git a/registrations/lib/registrations_web/router.ex b/registrations/lib/registrations_web/router.ex index cae4fcd4..802887d5 100644 --- a/registrations/lib/registrations_web/router.ex +++ b/registrations/lib/registrations_web/router.ex @@ -144,6 +144,7 @@ defmodule RegistrationsWeb.Router do pipe_through([:pow_json_api_protected_admin]) resources("/regions", RegionController, only: [:delete]) + resources("/user-roles", UserRoleController, only: [:index, :create, :delete]) end scope "/waydowntown", RegistrationsWeb do @@ -164,6 +165,12 @@ defmodule RegistrationsWeb.Router do get("/specifications/mine", SpecificationController, :mine, as: :my_specifications) resources("/submissions", SubmissionController, only: [:create, :show]) + + resources("/specification-validations", SpecificationValidationController, only: [:index, :show, :create, :update]) + get("/specification-validations/mine", SpecificationValidationController, :mine, as: :my_validations) + get("/specification-validations/oversee", SpecificationValidationController, :oversee, as: :oversee_validations) + + resources("/validation-comments", ValidationCommentController, only: [:create, :update, :delete]) end scope "/fixme", RegistrationsWeb do diff --git a/registrations/lib/registrations_web/views/session_view.ex b/registrations/lib/registrations_web/views/session_view.ex index d51292fb..2e8160a4 100644 --- a/registrations/lib/registrations_web/views/session_view.ex +++ b/registrations/lib/registrations_web/views/session_view.ex @@ -4,7 +4,15 @@ defmodule RegistrationsWeb.SessionView do alias RegistrationsWeb.SessionView def fields do - [:admin, :email, :name] + [:admin, :email, :name, :roles] + end + + def roles(user, _conn) do + if Ecto.assoc_loaded?(user.user_roles) do + Enum.map(user.user_roles, & &1.role) + else + [] + end end def render("show.json", %{conn: conn, params: params}) do diff --git a/registrations/lib/registrations_web/views/specification_validation_view.ex b/registrations/lib/registrations_web/views/specification_validation_view.ex new file mode 100644 index 00000000..ae14066e --- /dev/null +++ b/registrations/lib/registrations_web/views/specification_validation_view.ex @@ -0,0 +1,17 @@ +defmodule RegistrationsWeb.SpecificationValidationView do + use JSONAPI.View, type: "specification-validations" + + def fields do + [:status, :play_mode, :overall_notes] + end + + def relationships do + [ + specification: {RegistrationsWeb.Owner.SpecificationView, :include}, + validator: {RegistrationsWeb.JSONAPI.UserView, :include}, + assigned_by: {RegistrationsWeb.JSONAPI.UserView, :include}, + run: {RegistrationsWeb.RunView, :include}, + validation_comments: {RegistrationsWeb.ValidationCommentView, :include} + ] + end +end diff --git a/registrations/lib/registrations_web/views/user_role_view.ex b/registrations/lib/registrations_web/views/user_role_view.ex new file mode 100644 index 00000000..2171a3d1 --- /dev/null +++ b/registrations/lib/registrations_web/views/user_role_view.ex @@ -0,0 +1,14 @@ +defmodule RegistrationsWeb.UserRoleView do + use JSONAPI.View, type: "user-roles" + + def fields do + [:role] + end + + def relationships do + [ + user: {RegistrationsWeb.JSONAPI.UserView, :include}, + assigned_by: {RegistrationsWeb.JSONAPI.UserView, :include} + ] + end +end diff --git a/registrations/lib/registrations_web/views/validation_comment_view.ex b/registrations/lib/registrations_web/views/validation_comment_view.ex new file mode 100644 index 00000000..f6b0f83e --- /dev/null +++ b/registrations/lib/registrations_web/views/validation_comment_view.ex @@ -0,0 +1,14 @@ +defmodule RegistrationsWeb.ValidationCommentView do + use JSONAPI.View, type: "validation-comments" + + def fields do + [:field, :comment, :suggested_value] + end + + def relationships do + [ + answer: {RegistrationsWeb.Owner.AnswerView, :include}, + specification_validation: {RegistrationsWeb.SpecificationValidationView, :include} + ] + end +end diff --git a/registrations/priv/repo/migrations/20260301224046_create_user_roles.exs b/registrations/priv/repo/migrations/20260301224046_create_user_roles.exs new file mode 100644 index 00000000..228a3466 --- /dev/null +++ b/registrations/priv/repo/migrations/20260301224046_create_user_roles.exs @@ -0,0 +1,18 @@ +defmodule Registrations.Repo.Migrations.CreateUserRoles do + @moduledoc false + use Ecto.Migration + + def change do + create table(:user_roles, primary_key: false) do + add(:id, :uuid, primary_key: true, default: fragment("gen_random_uuid()")) + + add(:user_id, references("users", type: :uuid, on_delete: :delete_all), null: false) + add(:role, :string, null: false) + add(:assigned_by_id, references("users", type: :uuid, on_delete: :nilify_all)) + + timestamps() + end + + create(unique_index(:user_roles, [:user_id, :role])) + end +end diff --git a/registrations/priv/repo/migrations/20260301224047_create_specification_validations.exs b/registrations/priv/repo/migrations/20260301224047_create_specification_validations.exs new file mode 100644 index 00000000..5db30f43 --- /dev/null +++ b/registrations/priv/repo/migrations/20260301224047_create_specification_validations.exs @@ -0,0 +1,24 @@ +defmodule Registrations.Repo.Migrations.CreateSpecificationValidations do + @moduledoc false + use Ecto.Migration + + def change do + create table(:specification_validations, prefix: "waydowntown", primary_key: false) do + add(:id, :uuid, primary_key: true, default: fragment("gen_random_uuid()")) + + add(:specification_id, references(:specifications, type: :uuid, on_delete: :delete_all), null: false) + add(:validator_id, references("users", prefix: "public", type: :uuid, on_delete: :delete_all), null: false) + add(:assigned_by_id, references("users", prefix: "public", type: :uuid, on_delete: :nilify_all), null: false) + + add(:status, :string, null: false, default: "assigned") + add(:play_mode, :string) + add(:overall_notes, :text) + + add(:run_id, references(:runs, type: :uuid, on_delete: :nilify_all)) + + timestamps() + end + + create(unique_index(:specification_validations, [:specification_id, :validator_id], prefix: "waydowntown")) + end +end diff --git a/registrations/priv/repo/migrations/20260301224048_create_validation_comments.exs b/registrations/priv/repo/migrations/20260301224048_create_validation_comments.exs new file mode 100644 index 00000000..a4787bd7 --- /dev/null +++ b/registrations/priv/repo/migrations/20260301224048_create_validation_comments.exs @@ -0,0 +1,22 @@ +defmodule Registrations.Repo.Migrations.CreateValidationComments do + @moduledoc false + use Ecto.Migration + + def change do + create table(:validation_comments, prefix: "waydowntown", primary_key: false) do + add(:id, :uuid, primary_key: true, default: fragment("gen_random_uuid()")) + + add(:specification_validation_id, references(:specification_validations, type: :uuid, on_delete: :delete_all), + null: false + ) + + add(:answer_id, references(:answers, type: :uuid, on_delete: :delete_all)) + + add(:field, :string) + add(:comment, :text) + add(:suggested_value, :text) + + timestamps() + end + end +end diff --git a/registrations/test/support/factory.ex b/registrations/test/support/factory.ex index fb198486..9777301b 100644 --- a/registrations/test/support/factory.ex +++ b/registrations/test/support/factory.ex @@ -72,4 +72,16 @@ defmodule Registrations.Factory do def submission_factory do %Registrations.Waydowntown.Submission{} end + + def user_role_factory do + %Registrations.UserRole{} + end + + def specification_validation_factory do + %Registrations.Waydowntown.SpecificationValidation{} + end + + def validation_comment_factory do + %Registrations.Waydowntown.ValidationComment{} + end end diff --git a/waydowntown_app/.metadata b/waydowntown_app/.metadata index 58684b17..59982238 100644 --- a/waydowntown_app/.metadata +++ b/waydowntown_app/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" + revision: "67323de285b00232883f53b84095eb72be97d35c" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 - base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 - - platform: macos - create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 - base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + create_revision: 67323de285b00232883f53b84095eb72be97d35c + base_revision: 67323de285b00232883f53b84095eb72be97d35c + - platform: ios + create_revision: 67323de285b00232883f53b84095eb72be97d35c + base_revision: 67323de285b00232883f53b84095eb72be97d35c # User provided section diff --git a/waydowntown_app/integration_test/validation_test.dart b/waydowntown_app/integration_test/validation_test.dart new file mode 100644 index 00000000..f5bf77d4 --- /dev/null +++ b/waydowntown_app/integration_test/validation_test.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:waydowntown/app.dart'; +import 'package:waydowntown/services/user_service.dart'; + +import 'helpers.dart'; +import 'test_backend_client.dart'; +import 'test_config.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late TestBackendClient testClient; + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + dotenv.testLoad(fileInput: 'API_ROOT=${TestConfig.apiBaseUrl}'); + testClient = TestBackendClient(); + }); + + testWidgets('validator flow: view assignment, start, add comment, submit', + (tester) async { + // 1. Reset DB with validation game data + final resetData = await testClient.resetDatabase(game: 'validation'); + final validatorEmail = resetData['validator_email'] as String; + final validatorPassword = resetData['validator_password'] as String; + + // 2. Login as validator and set tokens + final tokens = await testClient.login(validatorEmail, validatorPassword); + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); + + // 3. Launch app + await tester.pumpWidget(const Waydowntown()); + await waitFor(tester, find.text(validatorEmail)); + + // 4. Tap Validate button (visible because user has validator role) + final validateButton = find.text('Validate'); + await waitFor(tester, validateButton); + await tester.tap(validateButton); + + // 5. Wait for My Validations screen with the assignment + await waitFor(tester, find.text('My Validations')); + await waitFor(tester, find.text('assigned')); + + // 6. Tap the assignment to open detail + await tester.tap(find.text('string_collector')); + + // 7. Wait for validation detail screen + await waitFor(tester, find.text('Play Mode')); + + // 8. Select "Play with answers" mode + await tester.tap(find.text('Play with answers')); + await tester.pumpAndSettle(); + + // 9. Tap "Start Validation" to move to in_progress + final startButton = find.text('Start Validation'); + await tester.scrollUntilVisible( + startButton, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(startButton); + + // 10. Wait for status to update to in_progress + await waitFor(tester, find.text('in progress')); + + // 11. Verify answer cards are shown + await waitFor(tester, find.text('Answers')); + + // 12. Expand first answer card to add a comment + final expandButtons = find.byIcon(Icons.expand_more); + await waitFor(tester, expandButtons); + await tester.tap(expandButtons.first); + await tester.pumpAndSettle(); + + // 13. Enter a comment + final commentField = find.widgetWithText(TextField, 'Comment'); + await tester.scrollUntilVisible( + commentField, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.enterText(commentField, 'This answer needs clarification'); + + // 14. Enter a suggested value + final suggestedField = find.widgetWithText(TextField, 'Suggested value'); + await tester.scrollUntilVisible( + suggestedField, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.enterText(suggestedField, 'green apple'); + + // 15. Save the comment + final addCommentButton = find.text('Add Comment'); + await tester.scrollUntilVisible( + addCommentButton, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(addCommentButton); + + // 16. Wait for comment badge to appear (indicates save succeeded) + await waitFor(tester, find.byIcon(Icons.comment), + timeout: const Duration(seconds: 10)); + + // 17. Add overall notes + final notesField = find.widgetWithText(TextField, + 'Add overall notes about this specification...'); + await tester.scrollUntilVisible( + notesField, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.enterText(notesField, 'Spec works but needs tweaks'); + + // 18. Submit the validation + final submitButton = find.text('Submit Validation'); + await tester.scrollUntilVisible( + submitButton, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(submitButton); + + // 19. Wait for status to change to submitted + await waitFor(tester, find.text('submitted')); + }); + + testWidgets('overseer flow: view submitted validation, accept it', + (tester) async { + // 1. Reset DB with validation game data + final resetData = await testClient.resetDatabase(game: 'validation'); + final validatorEmail = resetData['validator_email'] as String; + final validatorPassword = resetData['validator_password'] as String; + final overseerEmail = resetData['overseer_email'] as String; + final overseerPassword = resetData['overseer_password'] as String; + + // 2. First, login as validator and submit the validation + final validatorTokens = + await testClient.login(validatorEmail, validatorPassword); + final validatorDio = + testClient.createAuthenticatedDio(validatorTokens.accessToken); + + // Move validation to in_progress + final validationId = resetData['validation_id'] as String; + await validatorDio.patch( + '/waydowntown/specification-validations/$validationId', + data: { + 'data': { + 'type': 'specification-validations', + 'id': validationId, + 'attributes': { + 'status': 'in_progress', + 'play_mode': 'blind', + }, + } + }, + ); + + // Submit validation with notes + await validatorDio.patch( + '/waydowntown/specification-validations/$validationId', + data: { + 'data': { + 'type': 'specification-validations', + 'id': validationId, + 'attributes': { + 'status': 'submitted', + 'overall_notes': 'Looks good overall', + }, + } + }, + ); + + // 3. Now login as overseer + final overseerTokens = + await testClient.login(overseerEmail, overseerPassword); + await UserService.setTokens( + overseerTokens.accessToken, overseerTokens.renewalToken); + + // 4. Launch app + await tester.pumpWidget(const Waydowntown()); + await waitFor(tester, find.text(overseerEmail)); + + // 5. Tap Oversee button + final overseeButton = find.text('Oversee'); + await waitFor(tester, overseeButton); + await tester.tap(overseeButton); + + // 6. Wait for Overseer Dashboard + await waitFor(tester, find.text('Overseer Dashboard')); + + // 7. The "Pending Review" tab should show the submitted validation + await waitFor(tester, find.text('string_collector')); + + // 8. Tap to review + await tester.tap(find.text('string_collector')); + + // 9. Wait for review screen + await waitFor(tester, find.text('Review Validation')); + await waitFor(tester, find.text('submitted')); + + // 10. Verify validator's notes are shown + await waitFor(tester, find.text('Looks good overall')); + + // 11. Accept the validation + final acceptButton = find.text('Accept'); + await tester.scrollUntilVisible( + acceptButton, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(acceptButton); + + // 12. Verify status changes to accepted + await waitFor(tester, find.text('accepted')); + }); +} diff --git a/waydowntown_app/lib/models/specification_validation.dart b/waydowntown_app/lib/models/specification_validation.dart new file mode 100644 index 00000000..f6caad38 --- /dev/null +++ b/waydowntown_app/lib/models/specification_validation.dart @@ -0,0 +1,125 @@ +import 'package:waydowntown/models/specification.dart'; +import 'package:waydowntown/models/validation_comment.dart'; + +class SpecificationValidation { + final String id; + final String status; + final String? playMode; + final String? overallNotes; + final Specification? specification; + final String? validatorId; + final String? validatorName; + final String? assignedById; + final String? assignedByName; + final String? runId; + final List comments; + + const SpecificationValidation({ + required this.id, + required this.status, + this.playMode, + this.overallNotes, + this.specification, + this.validatorId, + this.validatorName, + this.assignedById, + this.assignedByName, + this.runId, + this.comments = const [], + }); + + factory SpecificationValidation.fromJson( + Map json, List included) { + final attributes = json['attributes'] as Map?; + final relationships = json['relationships'] as Map?; + + Specification? specification; + if (relationships != null && + relationships['specification'] != null && + relationships['specification']['data'] != null) { + final specData = relationships['specification']['data']; + final specJson = included.firstWhere( + (item) => + item['type'] == 'specifications' && item['id'] == specData['id'], + orElse: () => null, + ); + if (specJson != null) { + specification = Specification.fromJson(specJson, included); + } + } + + String? validatorId; + String? validatorName; + if (relationships != null && + relationships['validator'] != null && + relationships['validator']['data'] != null) { + validatorId = relationships['validator']['data']['id']; + final validatorJson = included.firstWhere( + (item) => + item['type'] == 'users' && item['id'] == validatorId, + orElse: () => null, + ); + if (validatorJson != null) { + validatorName = validatorJson['attributes']?['name'] ?? + validatorJson['attributes']?['email']; + } + } + + String? assignedById; + String? assignedByName; + if (relationships != null && + relationships['assigned-by'] != null && + relationships['assigned-by']['data'] != null) { + assignedById = relationships['assigned-by']['data']['id']; + final assignerJson = included.firstWhere( + (item) => + item['type'] == 'users' && item['id'] == assignedById, + orElse: () => null, + ); + if (assignerJson != null) { + assignedByName = assignerJson['attributes']?['name'] ?? + assignerJson['attributes']?['email']; + } + } + + String? runId; + if (relationships != null && + relationships['run'] != null && + relationships['run']['data'] != null) { + runId = relationships['run']['data']['id']; + } + + List comments = []; + if (relationships != null && + relationships['validation-comments'] != null && + relationships['validation-comments']['data'] != null) { + final commentDataList = + relationships['validation-comments']['data'] as List; + for (final commentData in commentDataList) { + final commentJson = included.firstWhere( + (item) => + item['type'] == 'validation-comments' && + item['id'] == commentData['id'], + orElse: () => null, + ); + if (commentJson != null) { + comments.add(ValidationComment.fromJson(commentJson, included)); + } + } + } + + return SpecificationValidation( + id: json['id'], + status: attributes?['status'] ?? 'assigned', + playMode: attributes?['play_mode'], + overallNotes: attributes?['overall_notes'], + specification: specification, + validatorId: validatorId, + validatorName: validatorName, + assignedById: assignedById, + assignedByName: assignedByName, + runId: runId, + comments: comments, + ); + } +} diff --git a/waydowntown_app/lib/models/validation_comment.dart b/waydowntown_app/lib/models/validation_comment.dart new file mode 100644 index 00000000..d8087f28 --- /dev/null +++ b/waydowntown_app/lib/models/validation_comment.dart @@ -0,0 +1,36 @@ +class ValidationComment { + final String id; + final String? answerId; + final String? field; + final String? comment; + final String? suggestedValue; + + const ValidationComment({ + required this.id, + this.answerId, + this.field, + this.comment, + this.suggestedValue, + }); + + factory ValidationComment.fromJson( + Map json, List included) { + final attributes = json['attributes'] as Map?; + final relationships = json['relationships'] as Map?; + + String? answerId; + if (relationships != null && + relationships['answer'] != null && + relationships['answer']['data'] != null) { + answerId = relationships['answer']['data']['id']; + } + + return ValidationComment( + id: json['id'], + answerId: answerId, + field: attributes?['field'], + comment: attributes?['comment'], + suggestedValue: attributes?['suggested_value'], + ); + } +} diff --git a/waydowntown_app/lib/routes/overseer_dashboard_route.dart b/waydowntown_app/lib/routes/overseer_dashboard_route.dart new file mode 100644 index 00000000..0c6d162e --- /dev/null +++ b/waydowntown_app/lib/routes/overseer_dashboard_route.dart @@ -0,0 +1,184 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:waydowntown/app.dart'; +import 'package:waydowntown/models/specification_validation.dart'; +import 'package:waydowntown/routes/review_validation_route.dart'; +import 'package:waydowntown/widgets/assign_validator_widget.dart'; + +class OverseerDashboardRoute extends StatefulWidget { + final Dio dio; + + const OverseerDashboardRoute({super.key, required this.dio}); + + @override + State createState() => + _OverseerDashboardRouteState(); +} + +class _OverseerDashboardRouteState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + List _validations = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + _fetchValidations(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _fetchValidations() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final response = await widget.dio + .get('/waydowntown/specification-validations/oversee'); + + if (response.statusCode == 200) { + final data = response.data['data'] as List; + final included = + (response.data['included'] as List?) ?? []; + setState(() { + _validations = data + .map((json) => + SpecificationValidation.fromJson(json, included)) + .toList(); + _isLoading = false; + }); + } + } catch (e) { + talker.error('Error fetching validations: $e'); + setState(() { + _error = 'Failed to load validations'; + _isLoading = false; + }); + } + } + + List _filterByTab(int tabIndex) { + switch (tabIndex) { + case 0: // Pending review + return _validations + .where((v) => v.status == 'submitted') + .toList(); + case 1: // Active + return _validations + .where( + (v) => v.status == 'assigned' || v.status == 'in_progress') + .toList(); + case 2: // Completed + return _validations + .where( + (v) => v.status == 'accepted' || v.status == 'rejected') + .toList(); + default: + return _validations; + } + } + + Color _statusColor(String status) { + switch (status) { + case 'assigned': + return Colors.blue; + case 'in_progress': + return Colors.orange; + case 'submitted': + return Colors.purple; + case 'accepted': + return Colors.green; + case 'rejected': + return Colors.red; + default: + return Colors.grey; + } + } + + Widget _buildList(List validations) { + if (validations.isEmpty) { + return const Center(child: Text('No validations')); + } + + return ListView.builder( + itemCount: validations.length, + itemBuilder: (context, index) { + final validation = validations[index]; + final spec = validation.specification; + return ListTile( + title: Text(spec?.concept ?? 'Unknown'), + subtitle: Text( + 'Validator: ${validation.validatorName ?? 'Unknown'}', + ), + trailing: Chip( + label: Text( + validation.status.replaceAll('_', ' '), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + backgroundColor: _statusColor(validation.status), + ), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ReviewValidationRoute( + dio: widget.dio, + validation: validation, + ), + ), + ); + _fetchValidations(); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Overseer Dashboard'), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Pending Review'), + Tab(text: 'Active'), + Tab(text: 'Completed'), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () async { + await showDialog( + context: context, + builder: (context) => AssignValidatorWidget(dio: widget.dio), + ); + _fetchValidations(); + }, + child: const Icon(Icons.add), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text(_error!)) + : TabBarView( + controller: _tabController, + children: [ + _buildList(_filterByTab(0)), + _buildList(_filterByTab(1)), + _buildList(_filterByTab(2)), + ], + ), + ); + } +} diff --git a/waydowntown_app/lib/routes/review_validation_route.dart b/waydowntown_app/lib/routes/review_validation_route.dart new file mode 100644 index 00000000..6b111d53 --- /dev/null +++ b/waydowntown_app/lib/routes/review_validation_route.dart @@ -0,0 +1,276 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:waydowntown/app.dart'; +import 'package:waydowntown/models/answer.dart'; +import 'package:waydowntown/models/specification_validation.dart'; + +class ReviewValidationRoute extends StatefulWidget { + final Dio dio; + final SpecificationValidation validation; + + const ReviewValidationRoute({ + super.key, + required this.dio, + required this.validation, + }); + + @override + State createState() => _ReviewValidationRouteState(); +} + +class _ReviewValidationRouteState extends State { + late SpecificationValidation _validation; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _validation = widget.validation; + } + + Future _refreshValidation() async { + try { + final response = await widget.dio + .get('/waydowntown/specification-validations/${_validation.id}'); + if (response.statusCode == 200) { + final included = + (response.data['included'] as List?) ?? []; + setState(() { + _validation = SpecificationValidation.fromJson( + response.data['data'], included); + }); + } + } catch (e) { + talker.error('Error refreshing validation: $e'); + } + } + + Future _updateStatus(String status) async { + setState(() => _isSaving = true); + + try { + await widget.dio.patch( + '/waydowntown/specification-validations/${_validation.id}', + data: { + 'data': { + 'type': 'specification-validations', + 'id': _validation.id, + 'attributes': {'status': status}, + } + }, + ); + await _refreshValidation(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Validation $status')), + ); + } + } catch (e) { + talker.error('Error updating status: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.toString()}')), + ); + } + } finally { + setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final spec = _validation.specification; + + return Scaffold( + appBar: AppBar( + title: const Text('Review Validation'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status + Card( + child: ListTile( + title: const Text('Status'), + subtitle: Text( + 'Validator: ${_validation.validatorName ?? 'Unknown'}'), + trailing: Chip( + label: Text( + _validation.status.replaceAll('_', ' '), + style: const TextStyle(color: Colors.white), + ), + backgroundColor: _statusColor(_validation.status), + ), + ), + ), + const SizedBox(height: 8), + + // Specification info + if (spec != null) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Specification', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text('Concept: ${spec.concept}'), + if (spec.startDescription != null) + Text('Start: ${spec.startDescription}'), + if (spec.taskDescription != null) + Text('Task: ${spec.taskDescription}'), + if (spec.region != null) + Text('Region: ${spec.region!.name}'), + ], + ), + ), + ), + const SizedBox(height: 8), + + // Play mode + if (_validation.playMode != null) + Card( + child: ListTile( + title: const Text('Play Mode'), + trailing: Text( + _validation.playMode!.replaceAll('_', ' ')), + ), + ), + const SizedBox(height: 8), + + // Overall notes + if (_validation.overallNotes != null) ...[ + Text("Validator\u2019s Notes", + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(_validation.overallNotes!), + ), + ), + const SizedBox(height: 16), + ], + + // Comments + if (_validation.comments.isNotEmpty) ...[ + Text('Comments', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ..._validation.comments.map((comment) { + // Find the matching answer + Answer? answer; + if (comment.answerId != null) { + answer = spec?.answers?.cast().firstWhere( + (a) => a?.id == comment.answerId, + orElse: () => null, + ); + } + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (answer != null && answer.label != null) + Chip(label: Text(answer.label!)), + if (comment.field != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Chip( + label: Text(comment.field!), + backgroundColor: Colors.grey[200], + ), + ), + ], + ), + if (comment.comment != null) ...[ + const SizedBox(height: 4), + Text(comment.comment!), + ], + if (comment.suggestedValue != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Text('Suggested: ', + style: TextStyle( + fontWeight: FontWeight.bold)), + Expanded( + child: Text( + comment.suggestedValue!, + style: const TextStyle( + fontStyle: FontStyle.italic), + ), + ), + ], + ), + ], + ], + ), + ), + ); + }), + const SizedBox(height: 16), + ], + + // Accept / Reject buttons + if (_validation.status == 'submitted') ...[ + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: + _isSaving ? null : () => _updateStatus('accepted'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: const Text('Accept'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: + _isSaving ? null : () => _updateStatus('rejected'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Reject'), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } + + Color _statusColor(String status) { + switch (status) { + case 'assigned': + return Colors.blue; + case 'in_progress': + return Colors.orange; + case 'submitted': + return Colors.purple; + case 'accepted': + return Colors.green; + case 'rejected': + return Colors.red; + default: + return Colors.grey; + } + } +} diff --git a/waydowntown_app/lib/routes/validation_detail_route.dart b/waydowntown_app/lib/routes/validation_detail_route.dart new file mode 100644 index 00000000..4b8edc82 --- /dev/null +++ b/waydowntown_app/lib/routes/validation_detail_route.dart @@ -0,0 +1,337 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:waydowntown/app.dart'; +import 'package:waydowntown/models/specification_validation.dart'; +import 'package:waydowntown/routes/request_run_route.dart'; +import 'package:waydowntown/widgets/validation_answer_card.dart'; + +class ValidationDetailRoute extends StatefulWidget { + final Dio dio; + final SpecificationValidation validation; + + const ValidationDetailRoute({ + super.key, + required this.dio, + required this.validation, + }); + + @override + State createState() => _ValidationDetailRouteState(); +} + +class _ValidationDetailRouteState extends State { + late SpecificationValidation _validation; + final _notesController = TextEditingController(); + String? _selectedPlayMode; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _validation = widget.validation; + _notesController.text = _validation.overallNotes ?? ''; + _selectedPlayMode = _validation.playMode; + } + + @override + void dispose() { + _notesController.dispose(); + super.dispose(); + } + + Future _refreshValidation() async { + try { + final response = await widget.dio + .get('/waydowntown/specification-validations/${_validation.id}'); + if (response.statusCode == 200) { + final included = + (response.data['included'] as List?) ?? []; + setState(() { + _validation = SpecificationValidation.fromJson( + response.data['data'], included); + _notesController.text = _validation.overallNotes ?? ''; + _selectedPlayMode = _validation.playMode; + }); + } + } catch (e) { + talker.error('Error refreshing validation: $e'); + } + } + + Future _updateValidation(Map attrs) async { + setState(() => _isSaving = true); + + try { + await widget.dio.patch( + '/waydowntown/specification-validations/${_validation.id}', + data: { + 'data': { + 'type': 'specification-validations', + 'id': _validation.id, + 'attributes': attrs, + } + }, + ); + await _refreshValidation(); + } catch (e) { + talker.error('Error updating validation: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.toString()}')), + ); + } + } finally { + setState(() => _isSaving = false); + } + } + + Future _startValidation() async { + if (_selectedPlayMode == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select a play mode first')), + ); + return; + } + + await _updateValidation({ + 'status': 'in_progress', + 'play_mode': _selectedPlayMode, + }); + } + + Future _playSpecification() async { + if (_validation.specification == null) return; + + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RequestRunRoute( + dio: widget.dio, + specificationId: _validation.specification!.id, + ), + ), + ); + + if (result != null && result is String) { + await _updateValidation({'run_id': result}); + } + + await _refreshValidation(); + } + + Future _submitValidation() async { + await _updateValidation({ + 'status': 'submitted', + 'overall_notes': _notesController.text, + }); + } + + bool get _canEdit => + _validation.status == 'assigned' || + _validation.status == 'in_progress'; + + @override + Widget build(BuildContext context) { + final spec = _validation.specification; + + return Scaffold( + appBar: AppBar( + title: Text(spec?.concept ?? 'Validation'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status card + Card( + child: ListTile( + title: const Text('Status'), + trailing: Chip( + label: Text( + _validation.status.replaceAll('_', ' '), + style: const TextStyle(color: Colors.white), + ), + backgroundColor: _statusColor(_validation.status), + ), + ), + ), + const SizedBox(height: 8), + + // Specification info + if (spec != null) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Concept: ${spec.concept}', + style: Theme.of(context).textTheme.titleMedium), + if (spec.startDescription != null) + Text('Start: ${spec.startDescription}'), + if (spec.taskDescription != null) + Text('Task: ${spec.taskDescription}'), + if (spec.region != null) + Text('Region: ${spec.region!.name}'), + ], + ), + ), + ), + const SizedBox(height: 8), + ], + + // Play mode selection (only when assigned) + if (_validation.status == 'assigned') ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Play Mode', + style: Theme.of(context).textTheme.titleMedium), + RadioListTile( + title: const Text('Play blind'), + subtitle: const Text( + 'Play without seeing the expected answers'), + value: 'blind', + groupValue: _selectedPlayMode, + onChanged: (v) => + setState(() => _selectedPlayMode = v), + ), + RadioListTile( + title: const Text('Play with answers'), + subtitle: const Text( + 'See expected answers while playing'), + value: 'with_answers', + groupValue: _selectedPlayMode, + onChanged: (v) => + setState(() => _selectedPlayMode = v), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isSaving ? null : _startValidation, + child: const Text('Start Validation'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + ], + + // Play button (when in progress) + if (_validation.status == 'in_progress') ...[ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + onPressed: _playSpecification, + label: const Text('Play Specification'), + ), + ), + const SizedBox(height: 16), + ], + + // Answer cards for commenting + if (_canEdit && + spec != null && + spec.answers != null && + spec.answers!.isNotEmpty) ...[ + Text('Answers', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ...spec.answers!.map((answer) => ValidationAnswerCard( + dio: widget.dio, + answer: answer, + validationId: _validation.id, + existingComments: _validation.comments + .where((c) => c.answerId == answer.id) + .toList(), + showExpectedAnswers: + _validation.playMode == 'with_answers', + onCommentSaved: _refreshValidation, + )), + const SizedBox(height: 16), + ], + + // Overall notes + if (_canEdit) ...[ + Text('Overall Notes', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + TextField( + controller: _notesController, + maxLines: 4, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Add overall notes about this specification...', + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isSaving + ? null + : () => _updateValidation( + {'overall_notes': _notesController.text}), + child: const Text('Save Notes'), + ), + ), + const SizedBox(height: 16), + ], + + // Submit button + if (_validation.status == 'in_progress') + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isSaving ? null : _submitValidation, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: const Text('Submit Validation'), + ), + ), + + // Read-only view for submitted/accepted/rejected + if (!_canEdit && _validation.overallNotes != null) ...[ + Text('Overall Notes', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(_validation.overallNotes!), + ), + ), + ], + ], + ), + ), + ); + } + + Color _statusColor(String status) { + switch (status) { + case 'assigned': + return Colors.blue; + case 'in_progress': + return Colors.orange; + case 'submitted': + return Colors.purple; + case 'accepted': + return Colors.green; + case 'rejected': + return Colors.red; + default: + return Colors.grey; + } + } +} diff --git a/waydowntown_app/lib/routes/validator_assignments_route.dart b/waydowntown_app/lib/routes/validator_assignments_route.dart new file mode 100644 index 00000000..bc6f82b5 --- /dev/null +++ b/waydowntown_app/lib/routes/validator_assignments_route.dart @@ -0,0 +1,130 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:waydowntown/app.dart'; +import 'package:waydowntown/models/specification_validation.dart'; +import 'package:waydowntown/routes/validation_detail_route.dart'; + +class ValidatorAssignmentsRoute extends StatefulWidget { + final Dio dio; + + const ValidatorAssignmentsRoute({super.key, required this.dio}); + + @override + State createState() => + _ValidatorAssignmentsRouteState(); +} + +class _ValidatorAssignmentsRouteState extends State { + List _validations = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _fetchAssignments(); + } + + Future _fetchAssignments() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final response = await widget.dio + .get('/waydowntown/specification-validations/mine'); + + if (response.statusCode == 200) { + final data = response.data['data'] as List; + final included = + (response.data['included'] as List?) ?? []; + setState(() { + _validations = data + .map((json) => + SpecificationValidation.fromJson(json, included)) + .toList(); + _isLoading = false; + }); + } + } catch (e) { + talker.error('Error fetching assignments: $e'); + setState(() { + _error = 'Failed to load assignments'; + _isLoading = false; + }); + } + } + + Color _statusColor(String status) { + switch (status) { + case 'assigned': + return Colors.blue; + case 'in_progress': + return Colors.orange; + case 'submitted': + return Colors.purple; + case 'accepted': + return Colors.green; + case 'rejected': + return Colors.red; + default: + return Colors.grey; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('My Validations')), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text(_error!)) + : _validations.isEmpty + ? const Center(child: Text('No assignments yet')) + : RefreshIndicator( + onRefresh: _fetchAssignments, + child: ListView.builder( + itemCount: _validations.length, + itemBuilder: (context, index) { + final validation = _validations[index]; + final spec = validation.specification; + return ListTile( + title: Text(spec?.concept ?? 'Unknown'), + subtitle: Text( + spec?.startDescription ?? + spec?.taskDescription ?? + 'No description', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: Chip( + label: Text( + validation.status.replaceAll('_', ' '), + style: const TextStyle( + color: Colors.white, fontSize: 12), + ), + backgroundColor: + _statusColor(validation.status), + ), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ValidationDetailRoute( + dio: widget.dio, + validation: validation, + ), + ), + ); + _fetchAssignments(); + }, + ); + }, + ), + ), + ); + } +} diff --git a/waydowntown_app/lib/services/user_service.dart b/waydowntown_app/lib/services/user_service.dart index 3c892c8d..be298838 100644 --- a/waydowntown_app/lib/services/user_service.dart +++ b/waydowntown_app/lib/services/user_service.dart @@ -9,15 +9,19 @@ class UserService { static const String _accessTokenKey = 'access_token'; static const String _renewalTokenKey = 'renewal_token'; static const String _userNameKey = 'user_name'; + static const String _userRolesKey = 'user_roles'; static Future setUserData(String userId, String email, bool isAdmin, - {String? name}) async { + {String? name, List? roles}) async { await _storage.write(key: _userIdKey, value: userId); await _storage.write(key: _userEmailKey, value: email); await _storage.write(key: _userIsAdminKey, value: isAdmin.toString()); if (name != null) { await _storage.write(key: _userNameKey, value: name); } + if (roles != null) { + await _storage.write(key: _userRolesKey, value: roles.join(',')); + } } static Future setTokens(String accessToken, String renewalToken) async { @@ -54,6 +58,21 @@ class UserService { await _storage.write(key: _userNameKey, value: name); } + static Future setRoles(List roles) async { + await _storage.write(key: _userRolesKey, value: roles.join(',')); + } + + static Future> getRoles() async { + final roles = await _storage.read(key: _userRolesKey); + if (roles == null || roles.isEmpty) return []; + return roles.split(','); + } + + static Future hasRole(String role) async { + final roles = await getRoles(); + return roles.contains(role); + } + static Future clearUserData() async { await _storage.delete(key: _userIdKey); await _storage.delete(key: _userEmailKey); @@ -61,5 +80,6 @@ class UserService { await _storage.delete(key: _userIsAdminKey); await _storage.delete(key: _accessTokenKey); await _storage.delete(key: _renewalTokenKey); + await _storage.delete(key: _userRolesKey); } } diff --git a/waydowntown_app/lib/tools/role_management.dart b/waydowntown_app/lib/tools/role_management.dart new file mode 100644 index 00000000..5a393979 --- /dev/null +++ b/waydowntown_app/lib/tools/role_management.dart @@ -0,0 +1,199 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:waydowntown/app.dart'; + +class RoleManagement extends StatefulWidget { + final Dio dio; + + const RoleManagement({super.key, required this.dio}); + + @override + State createState() => _RoleManagementState(); +} + +class _RoleManagementState extends State { + List> _userRoles = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchRoles(); + } + + Future _fetchRoles() async { + setState(() => _isLoading = true); + + try { + final response = await widget.dio.get('/waydowntown/user-roles'); + + if (response.statusCode == 200) { + final data = response.data['data'] as List; + final included = + (response.data['included'] as List?) ?? []; + + setState(() { + _userRoles = data.map((role) { + final userId = + role['relationships']?['user']?['data']?['id'] as String?; + final userJson = userId != null + ? included.firstWhere( + (item) => + item['type'] == 'users' && item['id'] == userId, + orElse: () => null, + ) + : null; + + return { + 'id': role['id'], + 'role': role['attributes']['role'], + 'user_id': userId, + 'user_email': userJson?['attributes']?['email'], + 'user_name': userJson?['attributes']?['name'], + }; + }).toList(); + _isLoading = false; + }); + } + } catch (e) { + talker.error('Error fetching roles: $e'); + setState(() => _isLoading = false); + } + } + + Future _removeRole(String roleId) async { + try { + await widget.dio.delete('/waydowntown/user-roles/$roleId'); + _fetchRoles(); + } catch (e) { + talker.error('Error removing role: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.toString()}')), + ); + } + } + } + + void _showAddRoleDialog() { + final emailController = TextEditingController(); + String selectedRole = 'validator'; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('Assign Role'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: emailController, + decoration: + const InputDecoration(labelText: 'User ID (UUID)'), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: selectedRole, + decoration: const InputDecoration(labelText: 'Role'), + items: const [ + DropdownMenuItem( + value: 'validator', child: Text('Validator')), + DropdownMenuItem( + value: 'validation_overseer', + child: Text('Validation Overseer')), + ], + onChanged: (v) { + if (v != null) { + setDialogState(() => selectedRole = v); + } + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + try { + await widget.dio.post( + '/waydowntown/user-roles', + data: { + 'data': { + 'type': 'user-roles', + 'attributes': { + 'role': selectedRole, + }, + 'relationships': { + 'user': { + 'data': { + 'type': 'users', + 'id': emailController.text, + } + }, + }, + } + }, + ); + if (context.mounted) Navigator.of(context).pop(); + _fetchRoles(); + } catch (e) { + talker.error('Error assigning role: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.toString()}')), + ); + } + } + }, + child: const Text('Assign'), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Role Management')), + floatingActionButton: FloatingActionButton( + onPressed: _showAddRoleDialog, + child: const Icon(Icons.add), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _userRoles.isEmpty + ? const Center(child: Text('No roles assigned')) + : RefreshIndicator( + onRefresh: _fetchRoles, + child: ListView.builder( + itemCount: _userRoles.length, + itemBuilder: (context, index) { + final role = _userRoles[index]; + return ListTile( + title: Text(role['user_name'] ?? + role['user_email'] ?? + role['user_id'] ?? + 'Unknown'), + subtitle: Text(role['role']), + leading: Icon( + role['role'] == 'validator' + ? Icons.verified_user + : Icons.supervisor_account, + ), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _removeRole(role['id']), + ), + ); + }, + ), + ), + ); + } +} diff --git a/waydowntown_app/lib/widgets/assign_validator_widget.dart b/waydowntown_app/lib/widgets/assign_validator_widget.dart new file mode 100644 index 00000000..5dd4ce69 --- /dev/null +++ b/waydowntown_app/lib/widgets/assign_validator_widget.dart @@ -0,0 +1,185 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:waydowntown/app.dart'; + +class AssignValidatorWidget extends StatefulWidget { + final Dio dio; + + const AssignValidatorWidget({super.key, required this.dio}); + + @override + State createState() => _AssignValidatorWidgetState(); +} + +class _AssignValidatorWidgetState extends State { + List> _specifications = []; + List> _validators = []; + String? _selectedSpecificationId; + String? _selectedValidatorId; + bool _isLoading = true; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + try { + final specResponse = + await widget.dio.get('/waydowntown/specifications'); + final roleResponse = await widget.dio.get( + '/waydowntown/user-roles', + queryParameters: {'role': 'validator'}, + ); + + final specs = (specResponse.data['data'] as List) + .map((s) => { + 'id': s['id'], + 'concept': s['attributes']['concept'], + 'start_description': s['attributes']['start_description'], + }) + .toList(); + + final included = + (roleResponse.data['included'] as List?) ?? []; + final validators = >[]; + final seenIds = {}; + + for (final role in roleResponse.data['data'] as List) { + final userId = + role['relationships']?['user']?['data']?['id'] as String?; + if (userId != null && !seenIds.contains(userId)) { + seenIds.add(userId); + final userJson = included.firstWhere( + (item) => item['type'] == 'users' && item['id'] == userId, + orElse: () => null, + ); + validators.add({ + 'id': userId, + 'name': userJson?['attributes']?['name'] ?? + userJson?['attributes']?['email'] ?? + userId, + }); + } + } + + setState(() { + _specifications = specs; + _validators = validators; + _isLoading = false; + }); + } catch (e) { + talker.error('Error loading data: $e'); + setState(() => _isLoading = false); + } + } + + Future _assignValidator() async { + if (_selectedSpecificationId == null || _selectedValidatorId == null) { + return; + } + + setState(() => _isSaving = true); + + try { + await widget.dio.post( + '/waydowntown/specification-validations', + data: { + 'data': { + 'type': 'specification-validations', + 'attributes': {}, + 'relationships': { + 'specification': { + 'data': { + 'type': 'specifications', + 'id': _selectedSpecificationId, + } + }, + 'validator': { + 'data': { + 'type': 'users', + 'id': _selectedValidatorId, + } + }, + }, + } + }, + ); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + talker.error('Error assigning validator: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.toString()}')), + ); + } + } finally { + setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Assign Validator'), + content: _isLoading + ? const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ) + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + value: _selectedSpecificationId, + decoration: const InputDecoration( + labelText: 'Specification'), + items: _specifications + .map((s) => DropdownMenuItem( + value: s['id'], + child: Text( + '${s['concept']}${s['start_description'] != null ? ' - ${s['start_description']}' : ''}', + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (v) => + setState(() => _selectedSpecificationId = v), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedValidatorId, + decoration: + const InputDecoration(labelText: 'Validator'), + items: _validators + .map((v) => DropdownMenuItem( + value: v['id'], + child: Text(v['name']), + )) + .toList(), + onChanged: (v) => + setState(() => _selectedValidatorId = v), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: _isSaving || + _selectedSpecificationId == null || + _selectedValidatorId == null + ? null + : _assignValidator, + child: const Text('Assign'), + ), + ], + ); + } +} diff --git a/waydowntown_app/lib/widgets/session_widget.dart b/waydowntown_app/lib/widgets/session_widget.dart index 6dbfca2b..5bfb24d9 100644 --- a/waydowntown_app/lib/widgets/session_widget.dart +++ b/waydowntown_app/lib/widgets/session_widget.dart @@ -1,10 +1,13 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:waydowntown/app.dart'; +import 'package:waydowntown/routes/overseer_dashboard_route.dart'; import 'package:waydowntown/routes/team_negotiation_route.dart'; +import 'package:waydowntown/routes/validator_assignments_route.dart'; import 'package:waydowntown/services/user_service.dart'; import 'package:waydowntown/tools/auth_form.dart'; import 'package:waydowntown/tools/my_specifications_table.dart'; +import 'package:waydowntown/tools/role_management.dart'; class SessionWidget extends StatefulWidget { final Dio dio; @@ -20,6 +23,7 @@ class _SessionWidgetState extends State { String? _email; String? _name; bool? _isAdmin; + List _roles = []; bool _isLoading = true; @override @@ -38,26 +42,33 @@ class _SessionWidgetState extends State { if (response.statusCode == 200) { final attributes = response.data['data']['attributes']; + final roles = (attributes['roles'] as List?) + ?.map((r) => r.toString()) + .toList() ?? + []; setState(() { _email = attributes['email']; _name = attributes['name']; _isAdmin = attributes['admin'] ?? false; + _roles = roles; _isLoading = false; }); await UserService.setUserData( response.data['data']['id'], _email!, _isAdmin!, - name: _name); + name: _name, roles: roles); return; } final email = await UserService.getUserEmail(); final name = await UserService.getUserName(); final isAdmin = await UserService.getUserIsAdmin(); + final roles = await UserService.getRoles(); setState(() { _email = email; _name = name; _isAdmin = isAdmin; + _roles = roles; _isLoading = false; }); } catch (error) { @@ -85,6 +96,7 @@ class _SessionWidgetState extends State { setState(() { _email = null; _isAdmin = null; + _roles = []; _isLoading = false; }); @@ -249,6 +261,41 @@ class _SessionWidgetState extends State { ), child: const Text('Team'), ), + if (_roles.contains('validator')) + ElevatedButton.icon( + icon: const Icon(Icons.verified_user), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ValidatorAssignmentsRoute(dio: widget.dio), + ), + ), + label: const Text('Validate'), + ), + if (_roles.contains('validation_overseer')) + ElevatedButton.icon( + icon: const Icon(Icons.supervisor_account), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + OverseerDashboardRoute(dio: widget.dio), + ), + ), + label: const Text('Oversee'), + ), + if (_isAdmin == true) + ElevatedButton.icon( + icon: const Icon(Icons.manage_accounts), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RoleManagement(dio: widget.dio), + ), + ), + label: const Text('Roles'), + ), ], ), ); diff --git a/waydowntown_app/lib/widgets/validation_answer_card.dart b/waydowntown_app/lib/widgets/validation_answer_card.dart new file mode 100644 index 00000000..a421f568 --- /dev/null +++ b/waydowntown_app/lib/widgets/validation_answer_card.dart @@ -0,0 +1,220 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:waydowntown/app.dart'; +import 'package:waydowntown/models/answer.dart'; +import 'package:waydowntown/models/validation_comment.dart'; + +class ValidationAnswerCard extends StatefulWidget { + final Dio dio; + final Answer answer; + final String validationId; + final List existingComments; + final bool showExpectedAnswers; + final VoidCallback onCommentSaved; + + const ValidationAnswerCard({ + super.key, + required this.dio, + required this.answer, + required this.validationId, + required this.existingComments, + required this.showExpectedAnswers, + required this.onCommentSaved, + }); + + @override + State createState() => _ValidationAnswerCardState(); +} + +class _ValidationAnswerCardState extends State { + bool _expanded = false; + final _commentController = TextEditingController(); + final _suggestedValueController = TextEditingController(); + String? _selectedField; + bool _isSaving = false; + + @override + void dispose() { + _commentController.dispose(); + _suggestedValueController.dispose(); + super.dispose(); + } + + Future _saveComment() async { + if (_commentController.text.isEmpty && + _suggestedValueController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter a comment or suggested value')), + ); + return; + } + + setState(() => _isSaving = true); + + try { + await widget.dio.post( + '/waydowntown/validation-comments', + data: { + 'data': { + 'type': 'validation-comments', + 'attributes': { + 'comment': _commentController.text.isNotEmpty + ? _commentController.text + : null, + 'suggested_value': _suggestedValueController.text.isNotEmpty + ? _suggestedValueController.text + : null, + 'field': _selectedField, + }, + 'relationships': { + 'specification-validation': { + 'data': { + 'type': 'specification-validations', + 'id': widget.validationId, + } + }, + 'answer': { + 'data': { + 'type': 'answers', + 'id': widget.answer.id, + } + }, + }, + } + }, + ); + _commentController.clear(); + _suggestedValueController.clear(); + setState(() => _selectedField = null); + widget.onCommentSaved(); + } catch (e) { + talker.error('Error saving comment: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.toString()}')), + ); + } + } finally { + setState(() => _isSaving = false); + } + } + + Future _deleteComment(String commentId) async { + try { + await widget.dio.delete('/waydowntown/validation-comments/$commentId'); + widget.onCommentSaved(); + } catch (e) { + talker.error('Error deleting comment: $e'); + } + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Column( + children: [ + ListTile( + title: Text(widget.answer.label ?? 'Answer'), + subtitle: widget.showExpectedAnswers && widget.answer.answer != null + ? Text('Expected: ${widget.answer.answer}') + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.existingComments.isNotEmpty) + Badge( + label: Text('${widget.existingComments.length}'), + child: const Icon(Icons.comment), + ), + IconButton( + icon: Icon(_expanded + ? Icons.expand_less + : Icons.expand_more), + onPressed: () => + setState(() => _expanded = !_expanded), + ), + ], + ), + ), + if (_expanded) ...[ + const Divider(), + // Existing comments + if (widget.existingComments.isNotEmpty) + ...widget.existingComments.map((comment) => ListTile( + dense: true, + title: Text(comment.comment ?? ''), + subtitle: comment.suggestedValue != null + ? Text( + 'Suggested: ${comment.suggestedValue}', + style: const TextStyle( + fontStyle: FontStyle.italic), + ) + : null, + leading: comment.field != null + ? Chip( + label: Text(comment.field!, + style: const TextStyle(fontSize: 10)), + ) + : null, + trailing: IconButton( + icon: const Icon(Icons.delete, size: 18), + onPressed: () => _deleteComment(comment.id), + ), + )), + + // New comment form + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + value: _selectedField, + decoration: + const InputDecoration(labelText: 'Field'), + items: const [ + DropdownMenuItem( + value: null, child: Text('General')), + DropdownMenuItem( + value: 'answer', child: Text('Answer')), + DropdownMenuItem( + value: 'label', child: Text('Label')), + DropdownMenuItem( + value: 'hint', child: Text('Hint')), + ], + onChanged: (v) => + setState(() => _selectedField = v), + ), + const SizedBox(height: 8), + TextField( + controller: _commentController, + decoration: const InputDecoration( + labelText: 'Comment', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + const SizedBox(height: 8), + TextField( + controller: _suggestedValueController, + decoration: const InputDecoration( + labelText: 'Suggested value', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _isSaving ? null : _saveComment, + child: const Text('Add Comment'), + ), + ], + ), + ), + ], + ], + ), + ); + } +}