366 lines
12 KiB
Elixir
366 lines
12 KiB
Elixir
defmodule WhoNeedHelp.Release do
|
|
@moduledoc """
|
|
Used for executing DB release tasks when run in production without Mix
|
|
installed.
|
|
"""
|
|
@app :who_need_help
|
|
|
|
import Ecto.Query
|
|
|
|
def migrate do
|
|
load_app()
|
|
|
|
for repo <- repos() do
|
|
{:ok, _, _} =
|
|
Ecto.Migrator.with_repo(repo, fn repo ->
|
|
Ecto.Migrator.run(repo, :up, all: true)
|
|
WhoNeedHelp.Catalog.seed_defaults()
|
|
end)
|
|
end
|
|
end
|
|
|
|
def rollback(repo, version) do
|
|
load_app()
|
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
|
end
|
|
|
|
def bootstrap_admin(email) when is_binary(email) do
|
|
WhoNeedHelp.Repo.transact(fn ->
|
|
WhoNeedHelp.Repo.query!(
|
|
"SELECT pg_advisory_xact_lock(hashtext($1))",
|
|
["who_need_help.bootstrap_admin"]
|
|
)
|
|
|
|
admin_count =
|
|
WhoNeedHelp.Repo.aggregate(
|
|
from(assignment in WhoNeedHelp.Accounts.StaffRoleAssignment,
|
|
where: assignment.role == :admin
|
|
),
|
|
:count
|
|
)
|
|
|
|
cond do
|
|
admin_count > 0 ->
|
|
{:error, :admin_already_exists}
|
|
|
|
user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) ->
|
|
with {:ok, _assignment} <-
|
|
%WhoNeedHelp.Accounts.StaffRoleAssignment{}
|
|
|> WhoNeedHelp.Accounts.StaffRoleAssignment.changeset(%{
|
|
user_id: user.id,
|
|
role: :admin
|
|
})
|
|
|> WhoNeedHelp.Repo.insert(),
|
|
{:ok, _audit} <-
|
|
WhoNeedHelp.Trust.audit(nil, "user.admin_bootstrapped", "user", user.id) do
|
|
{:ok, %{id: user.id, email: user.email, roles: [:admin]}}
|
|
end
|
|
|
|
true ->
|
|
{:error, :user_not_found}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@play_review_request_title "Play review — medicine pickup"
|
|
@play_review_activity_title "Play review — public cinema meetup"
|
|
|
|
@doc """
|
|
Checks whether two existing ordinary accounts can be used for Google Play
|
|
review without creating or changing any records.
|
|
"""
|
|
def play_review_readiness(reviewer_email, counterpart_email)
|
|
when is_binary(reviewer_email) and is_binary(counterpart_email) do
|
|
load_app()
|
|
|
|
with {:ok, reviewer} <- play_review_user(reviewer_email, :reviewer),
|
|
{:ok, counterpart} <- play_review_user(counterpart_email, :counterpart),
|
|
:ok <- different_play_review_users(reviewer, counterpart),
|
|
{:ok, medicine_category} <- active_category("medicine-pickup", :help),
|
|
{:ok, cinema_category} <- active_category("cinema-meetup", :activity) do
|
|
{:ok,
|
|
%{
|
|
reviewer: reviewer,
|
|
counterpart: counterpart,
|
|
medicine_category: medicine_category,
|
|
cinema_category: cinema_category
|
|
}}
|
|
end
|
|
end
|
|
|
|
def play_review_readiness(_reviewer_email, _counterpart_email),
|
|
do: {:error, :invalid_email}
|
|
|
|
@doc """
|
|
Prepares stable, synthetic records for Google Play review using two existing
|
|
ordinary accounts.
|
|
|
|
Both accounts must already be confirmed, active, have accepted the terms,
|
|
have a password, and have no staff roles. The function never creates or
|
|
changes credentials. Repeating it returns the same request and activity.
|
|
"""
|
|
def prepare_play_review(reviewer_email, counterpart_email)
|
|
when is_binary(reviewer_email) and is_binary(counterpart_email) do
|
|
with {:ok,
|
|
%{
|
|
reviewer: reviewer,
|
|
counterpart: counterpart,
|
|
medicine_category: medicine_category,
|
|
cinema_category: cinema_category
|
|
}} <- play_review_readiness(reviewer_email, counterpart_email),
|
|
{:ok, request} <-
|
|
ensure_play_review_request(reviewer, counterpart, medicine_category),
|
|
{:ok, activity} <-
|
|
ensure_play_review_activity(reviewer, counterpart, cinema_category) do
|
|
{:ok,
|
|
%{
|
|
reviewer_id: reviewer.id,
|
|
counterpart_id: counterpart.id,
|
|
request_id: request.id,
|
|
request_path: "/requests/#{request.id}",
|
|
activity_id: activity.id,
|
|
activity_path: "/activities/#{activity.id}"
|
|
}}
|
|
end
|
|
end
|
|
|
|
def prepare_play_review(_reviewer_email, _counterpart_email),
|
|
do: {:error, :invalid_email}
|
|
|
|
defp play_review_user(email, role) do
|
|
email = email |> String.trim() |> String.downcase()
|
|
|
|
case WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) do
|
|
nil ->
|
|
{:error, {role, :user_not_found}}
|
|
|
|
user ->
|
|
cond do
|
|
user.moderation_status != :active ->
|
|
{:error, {role, :account_not_active}}
|
|
|
|
is_nil(user.confirmed_at) ->
|
|
{:error, {role, :email_not_confirmed}}
|
|
|
|
is_nil(user.accepted_terms_at) ->
|
|
{:error, {role, :terms_not_accepted}}
|
|
|
|
not (is_binary(user.hashed_password) and byte_size(user.hashed_password) > 0) ->
|
|
{:error, {role, :password_not_set}}
|
|
|
|
WhoNeedHelp.Accounts.staff_roles(user) != [] ->
|
|
{:error, {role, :staff_account_forbidden}}
|
|
|
|
true ->
|
|
{:ok, user}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp different_play_review_users(%{id: id}, %{id: id}),
|
|
do: {:error, :accounts_must_be_distinct}
|
|
|
|
defp different_play_review_users(_reviewer, _counterpart), do: :ok
|
|
|
|
defp active_category(slug, mode) do
|
|
case WhoNeedHelp.Repo.get_by(WhoNeedHelp.Catalog.Category,
|
|
slug: slug,
|
|
mode: mode,
|
|
active: true
|
|
) do
|
|
nil -> {:error, {:category_not_found, slug}}
|
|
category -> {:ok, category}
|
|
end
|
|
end
|
|
|
|
defp ensure_play_review_request(reviewer, counterpart, category) do
|
|
existing =
|
|
WhoNeedHelp.Help.HelpRequest
|
|
|> where(
|
|
[request],
|
|
request.requester_id == ^reviewer.id and
|
|
request.title == ^@play_review_request_title and
|
|
is_nil(request.hidden_at)
|
|
)
|
|
|> order_by([request], desc: request.inserted_at)
|
|
|> limit(1)
|
|
|> WhoNeedHelp.Repo.one()
|
|
|
|
with {:ok, request} <- reusable_or_create_request(existing, reviewer, category),
|
|
{:ok, _assignment} <- ensure_play_review_assignment(request, counterpart) do
|
|
{:ok, WhoNeedHelp.Repo.get!(WhoNeedHelp.Help.HelpRequest, request.id)}
|
|
end
|
|
end
|
|
|
|
defp reusable_or_create_request(
|
|
%{status: status} = request,
|
|
_reviewer,
|
|
_category
|
|
)
|
|
when status in [:open, :matched, :in_progress],
|
|
do: {:ok, request}
|
|
|
|
defp reusable_or_create_request(nil, reviewer, category) do
|
|
WhoNeedHelp.Help.create_request(WhoNeedHelp.Accounts.Scope.for_user(reviewer), %{
|
|
"title" => @play_review_request_title,
|
|
"description" =>
|
|
"Synthetic Google Play review flow. Coordinate only with the assigned review counterpart.",
|
|
"pickup_instructions" =>
|
|
"Synthetic review data only; no medicine, payment, or real-world handover is required.",
|
|
"structured_data" => %{"pickup_status" => "already_paid"},
|
|
"location_label" => "Kyiv public review area — synthetic data",
|
|
"latitude" => 50.4501,
|
|
"longitude" => 30.5234,
|
|
"location_radius_meters" => 1_000,
|
|
"location_visibility" => "approximate_public",
|
|
"urgency" => "scheduled",
|
|
"expires_at" => DateTime.add(DateTime.utc_now(:second), 120, :day),
|
|
"category_id" => category.id,
|
|
"safety_confirmed" => true
|
|
})
|
|
end
|
|
|
|
defp reusable_or_create_request(_finished_request, _reviewer, _category),
|
|
do: {:error, :review_request_not_reusable}
|
|
|
|
defp ensure_play_review_assignment(request, counterpart) do
|
|
assignment =
|
|
WhoNeedHelp.Repo.get_by(WhoNeedHelp.Help.Assignment,
|
|
request_id: request.id,
|
|
active: true
|
|
)
|
|
|
|
case assignment do
|
|
nil when request.status == :open ->
|
|
WhoNeedHelp.Help.accept_request(
|
|
WhoNeedHelp.Accounts.Scope.for_user(counterpart),
|
|
request.id
|
|
)
|
|
|
|
%{helper_id: helper_id} = assignment when helper_id == counterpart.id ->
|
|
{:ok, assignment}
|
|
|
|
nil ->
|
|
{:error, :review_assignment_missing}
|
|
|
|
_other_assignment ->
|
|
{:error, :review_request_assigned_to_another_user}
|
|
end
|
|
end
|
|
|
|
defp ensure_play_review_activity(reviewer, counterpart, category) do
|
|
existing =
|
|
WhoNeedHelp.Activities.Activity
|
|
|> where(
|
|
[activity],
|
|
activity.creator_id == ^reviewer.id and
|
|
activity.title == ^@play_review_activity_title and
|
|
is_nil(activity.hidden_at)
|
|
)
|
|
|> order_by([activity], desc: activity.inserted_at)
|
|
|> limit(1)
|
|
|> WhoNeedHelp.Repo.one()
|
|
|
|
with {:ok, activity} <- reusable_or_create_activity(existing, reviewer, category),
|
|
{:ok, participant} <- ensure_play_review_participant(activity, counterpart),
|
|
{:ok, _participant} <- approve_play_review_participant(activity, participant, reviewer) do
|
|
{:ok, WhoNeedHelp.Repo.get!(WhoNeedHelp.Activities.Activity, activity.id)}
|
|
end
|
|
end
|
|
|
|
defp reusable_or_create_activity(%{status: :open} = activity, _reviewer, _category),
|
|
do: {:ok, activity}
|
|
|
|
defp reusable_or_create_activity(nil, reviewer, category) do
|
|
now = DateTime.utc_now(:second)
|
|
|
|
WhoNeedHelp.Activities.create_activity(WhoNeedHelp.Accounts.Scope.for_user(reviewer), %{
|
|
"title" => @play_review_activity_title,
|
|
"description" =>
|
|
"Synthetic Google Play review meetup in a public place. No real attendance is expected.",
|
|
"structured_data" => %{"film_or_genre" => "Synthetic public screening"},
|
|
"location_label" => "Kyiv public cinema area — synthetic data",
|
|
"latitude" => 50.4508,
|
|
"longitude" => 30.5248,
|
|
"location_visibility" => "approximate_public",
|
|
"starts_at" => DateTime.add(now, 120, :day),
|
|
"join_deadline" => DateTime.add(now, 110, :day),
|
|
"capacity" => 4,
|
|
"category_id" => category.id,
|
|
"safety_confirmed" => true
|
|
})
|
|
end
|
|
|
|
defp reusable_or_create_activity(_finished_activity, _reviewer, _category),
|
|
do: {:error, :review_activity_not_reusable}
|
|
|
|
defp ensure_play_review_participant(activity, counterpart) do
|
|
case WhoNeedHelp.Repo.get_by(WhoNeedHelp.Activities.Participant,
|
|
activity_id: activity.id,
|
|
user_id: counterpart.id
|
|
) do
|
|
nil ->
|
|
WhoNeedHelp.Activities.request_to_join(
|
|
WhoNeedHelp.Accounts.Scope.for_user(counterpart),
|
|
activity.id
|
|
)
|
|
|
|
%{status: status} = participant when status in [:requested, :approved] ->
|
|
{:ok, participant}
|
|
|
|
_participant ->
|
|
{:error, :review_participant_not_reusable}
|
|
end
|
|
end
|
|
|
|
defp approve_play_review_participant(_activity, %{status: :approved} = participant, _reviewer),
|
|
do: {:ok, participant}
|
|
|
|
defp approve_play_review_participant(activity, %{status: :requested} = participant, reviewer) do
|
|
case WhoNeedHelp.Activities.approve_participant(
|
|
WhoNeedHelp.Accounts.Scope.for_user(reviewer),
|
|
participant.id
|
|
) do
|
|
{:ok, _activity} ->
|
|
{:ok,
|
|
WhoNeedHelp.Repo.get_by!(WhoNeedHelp.Activities.Participant,
|
|
activity_id: activity.id,
|
|
user_id: participant.user_id
|
|
)}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
def await_migrations do
|
|
load_app()
|
|
Enum.each(repos(), &await_repo/1)
|
|
end
|
|
|
|
defp await_repo(repo) do
|
|
{:ok, ready?, _} =
|
|
Ecto.Migrator.with_repo(repo, fn started_repo ->
|
|
case Ecto.Migrator.migrations(started_repo) do
|
|
[] -> false
|
|
migrations -> Enum.all?(migrations, fn {status, _version, _name} -> status == :up end)
|
|
end
|
|
end)
|
|
|
|
unless ready? do
|
|
Process.sleep(1_000)
|
|
await_repo(repo)
|
|
end
|
|
end
|
|
|
|
defp repos do
|
|
Application.fetch_env!(@app, :ecto_repos)
|
|
end
|
|
|
|
defp load_app do
|
|
# Many platforms require SSL when connecting to the database
|
|
Application.ensure_all_started(:ssl)
|
|
Application.ensure_loaded(@app)
|
|
end
|
|
end
|