Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,10 @@ cloud_cron = [
{"0 15 * * *", Plausible.Workers.NotifyAnnualRenewal},
# Every midnight
{"0 0 * * *", Plausible.Workers.LockSites},
# Daily at 6, ahead of ScanInactiveTeams - restarts the notice cycle for
# any lapsed snoozes so they're immediately eligible again same-day
# TODO: enable
# {"0 6 * * *", Plausible.Workers.UnsnoozeTeamDeletions},
# Daily at 7, ahead of AcceptTrafficUntil/SendTrialNotifications
# TODO: enable
# {"0 7 * * *", Plausible.Workers.ScanInactiveTeams},
Expand Down Expand Up @@ -891,6 +895,7 @@ cloud_queues = [
notify_annual_renewal: 1,
lock_sites: 1,
scan_inactive_teams: 1,
unsnooze_team_deletions: 1,
deletion_notification_emails: 1,
execute_team_deletions: 1,
legacy_time_on_page_cutoff: 1,
Expand Down
6 changes: 3 additions & 3 deletions extra/lib/plausible/customer_support/resource/team.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do
left_lateral_join: s in subquery(Teams.last_subscription_join_query()),
on: true,
order_by: [desc: :id],
preload: [owners: o, subscription: s]
preload: [:team_deletion_schedule, owners: o, subscription: s]
)

Plausible.Repo.all(q)
Expand All @@ -39,7 +39,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do
as: :team,
inner_join: o in assoc(t, :owners),
where: t.identifier == ^input,
preload: [owners: o]
preload: [:team_deletion_schedule, owners: o]
)
else
from(t in Plausible.Teams.Team,
Expand All @@ -56,7 +56,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do
desc: fragment("?.email = ?", o, ^input),
asc: t.name
],
preload: [owners: o]
preload: [:team_deletion_schedule, owners: o]
)
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ defmodule PlausibleWeb.CustomerSupport.Components.SearchResult do
>
$
</span>
<span
:if={@resource.object.team_deletion_schedule}
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800"
title="Deletion pending"
>
🧨

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loose idea: "🧨" vs "🧨🧨🧨" could differentiate the ones that have first notice sent from the ones that already had a reminder sent 😄

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's not much room in there but keep them coming 😃

</span>
</div>

<hr class="mt-4 mb-4 flex-grow border-t border-gray-200 dark:border-gray-600" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,28 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do
use PlausibleWeb, :live_component
import PlausibleWeb.CustomerSupport.Live

alias Plausible.TeamDeletionSchedule
alias Plausible.TeamDeletionSchedules

def update(%{team: team}, socket) do
changeset = Plausible.Teams.Team.crm_changeset(team, %{})
form = to_form(changeset)
schedule = TeamDeletionSchedules.active_schedule_for_team(team)
snooze_form = schedule && to_form(TeamDeletionSchedule.crm_changeset(schedule, %{}))

{:ok, assign(socket, team: team, form: form)}
{:ok, assign(socket, team: team, form: form, schedule: schedule, snooze_form: snooze_form)}
end

def render(assigns) do
~H"""
<div class="mt-8">
<.deletion_schedule
:if={@schedule}
schedule={@schedule}
snooze_form={@snooze_form}
myself={@myself}
/>

<.form :let={f} for={@form} phx-submit="save-team" phx-target={@myself}>
<.input field={f[:trial_expiry_date]} type="date" label="Trial Expiry Date" />
<.input field={f[:accept_traffic_until]} type="date" label="Accept traffic Until" />
Expand Down Expand Up @@ -45,15 +57,98 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do
"""
end

attr :schedule, :any, required: true
attr :snooze_form, :any, required: true
attr :myself, :any, required: true

defp deletion_schedule(assigns) do
~H"""
<div class="mb-6">
<.notice theme={notice_theme(@schedule.status)} title="Deletion scheduled">
{deletion_sentence(@schedule)}

<div :if={@schedule.status == :snoozed} class="mt-3">
<p class="text-sm text-gray-600 dark:text-gray-400">
Snoozed until
<strong>{@schedule.snoozed_until}</strong><span :if={@schedule.snooze_note}> — "{@schedule.snooze_note}"</span>.
</p>

<.button
class="mt-2"
phx-click="unsnooze-schedule"
phx-target={@myself}
data-confirm="Resume the deletion schedule now? This restarts the notice cycle."
>
Unsnooze
</.button>
</div>

<.form
:let={f}
:if={@schedule.status != :snoozed}
for={@snooze_form}
phx-submit="snooze-schedule"
phx-target={@myself}
class="mt-3 flex items-end gap-x-4"
>
<.input field={f[:snoozed_until]} type="date" label="Snooze until" />
<.input field={f[:snooze_note]} type="text" label="Note (optional)" />
<.button type="submit">Snooze</.button>
</.form>
</.notice>
</div>
"""
end

defp notice_theme(:snoozed), do: :gray
defp notice_theme(_), do: :yellow

defp deletion_sentence(schedule) do
category =
case schedule.category do
:expired_trial -> "expired trial"
:churned_subscription -> "churned subscription"
end

status_detail =
case schedule.status do
:scheduled ->
"Pending. First notice due #{schedule.first_notice_due_date}."

:first_notice_sent ->
"First notice sent #{format_dt(schedule.first_notice_sent_at)}."

:reminder_sent ->
"Reminder sent #{format_dt(schedule.reminder_sent_at)}."

:snoozed ->
"Snoozed."

:cancelled ->
"Cancelled."

:completed ->
"Completed."
end

"#{String.capitalize(category)}. Stats deletion on #{schedule.deletion_date}. #{status_detail}"
end

defp format_dt(nil), do: "N/A"
defp format_dt(%NaiveDateTime{} = dt), do: NaiveDateTime.to_date(dt) |> Date.to_string()

def handle_event("save-team", %{"team" => params}, socket) do
changeset = Plausible.Teams.Team.crm_changeset(socket.assigns.team, params)

# TODO: if this prolongs trial_expiry_date (or otherwise makes the team
# eligible again) cancely any Plausible.TeamDeletionSchedule
case Plausible.Repo.update(changeset) do
{:ok, team} ->
# Prolonging trial_expiry_date (or otherwise making the team
# eligible again) cancels any pending deletion schedule.
TeamDeletionSchedules.cancel_for_team(team)
schedule = TeamDeletionSchedules.active_schedule_for_team(team)

success("Team saved")
{:noreply, assign(socket, team: team, form: to_form(changeset))}
{:noreply, assign(socket, team: team, form: to_form(changeset), schedule: schedule)}

{:error, changeset} ->
failure("Error saving team: #{inspect(changeset.errors)}")
Expand All @@ -73,4 +168,37 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do
{:noreply, socket}
end
end

def handle_event("snooze-schedule", %{"team_deletion_schedule" => params}, socket) do
changeset = TeamDeletionSchedule.crm_changeset(socket.assigns.schedule, params)

if changeset.valid? do
until_date = Ecto.Changeset.get_change(changeset, :snoozed_until)
note = Ecto.Changeset.get_change(changeset, :snooze_note)

case TeamDeletionSchedules.snooze(socket.assigns.schedule, until_date, note: note) do
{:ok, schedule} ->
success("Deletion snoozed until #{until_date}")
{:noreply, assign(socket, schedule: schedule)}

{:error, {:invalid_transition, _, _}} ->
failure("Could not snooze - schedule is no longer in a snoozable state")
{:noreply, socket}
end
else
{:noreply, assign(socket, snooze_form: to_form(%{changeset | action: :validate}))}
end
end

def handle_event("unsnooze-schedule", _params, socket) do
case TeamDeletionSchedules.unsnooze(socket.assigns.schedule) do
{:ok, schedule} ->
success("Deletion schedule resumed")
{:noreply, assign(socket, schedule: schedule)}

{:error, {:invalid_transition, _, _}} ->
failure("Could not resume - schedule is not currently snoozed")
{:noreply, socket}
end
end
end
21 changes: 21 additions & 0 deletions lib/plausible/team_deletion_schedule.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ defmodule Plausible.TeamDeletionSchedule do

use Ecto.Schema

import Ecto.Changeset

@categories [:expired_trial, :churned_subscription]
@statuses [:scheduled, :first_notice_sent, :reminder_sent, :completed, :cancelled, :snoozed]

Expand Down Expand Up @@ -42,4 +44,23 @@ defmodule Plausible.TeamDeletionSchedule do

@spec active_statuses() :: [atom()]
def active_statuses, do: @statuses -- @terminal_statuses

@doc """
Validates staff submitted snooze input from the CRM - snoozed_until is
required and must be in the future. Doesn't touch status, the actual
transition happens via Plausible.TeamDeletionSchedules.snooze/3.
"""
@spec crm_changeset(t(), map()) :: Ecto.Changeset.t()
def crm_changeset(schedule, params) do
schedule
|> cast(params, [:snoozed_until, :snooze_note])
|> validate_required([:snoozed_until])
|> validate_change(:snoozed_until, fn field, date ->
if Date.after?(date, Date.utc_today()) do
[]
else
[{field, "must be in the future"}]
end
end)
end
end
69 changes: 56 additions & 13 deletions lib/plausible/team_deletion_schedules.ex
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,33 @@ defmodule Plausible.TeamDeletionSchedules do
end

@doc """
Cancels any pending deletion schedule for a team
Cancels any pending deletion schedule for a team that's no longer
eligible for it - either its subscription became active, or (for a
schedule based on an expired trial) its trial_expiry_date got prolonged
past today, e.g. by staff via the CRM.
"""
@spec cancel_for_team(Teams.Team.t()) :: non_neg_integer()
@spec cancel_for_team(Teams.Team.t()) :: :no_schedule | :ok
def cancel_for_team(team) do
team = Teams.with_subscription(team)

if Subscriptions.active?(team.subscription) do
if should_cancel?(team) do
cancel_active_schedule(team.id)
else
0
:no_schedule
end
end

defp should_cancel?(%{subscription: nil} = team) do
# Teams.on_trial?/1 treats a cleared trial_expiry_date as "not on
# trial", but here a cleared date means there's no trial-based justification
# for the schedule at all, so it should cancel too.
Teams.on_trial?(team) or is_nil(team.trial_expiry_date)
Comment thread
zoldar marked this conversation as resolved.
end

defp should_cancel?(team) do
Subscriptions.active?(team.subscription)
end

@doc """
Schedules due for their first notice - still scheduled and
past their first_notice_due_date.
Expand Down Expand Up @@ -125,6 +139,30 @@ defmodule Plausible.TeamDeletionSchedules do
)
end

@doc """
Schedules whose snooze has lapsed: still snoozed, past their
snoozed_until date.
"""
@spec due_for_unsnooze(Date.t()) :: [TeamDeletionSchedule.t()]
def due_for_unsnooze(today \\ Date.utc_today()) do
Repo.all(
from(sch in TeamDeletionSchedule,
where: sch.status == :snoozed,
where: sch.snoozed_until <= ^today
)
)
end

@doc """
Get the team's current active (non-terminal) deletion schedule, if any
"""
@spec active_schedule_for_team(Teams.Team.t()) :: TeamDeletionSchedule.t() | nil
def active_schedule_for_team(team) do
team.id
|> active_schedule_query()
|> Repo.one()
end

@doc """
Pending, non-backlog expired trial schedules for the
given team ids, keyed by `team_id`
Expand Down Expand Up @@ -248,30 +286,35 @@ defmodule Plausible.TeamDeletionSchedules do
end

defp cancel_active_schedule(team_id) do
{:ok, count} =
{:ok, result} =
Repo.transact(fn ->
case active_schedule_for(team_id) do
nil -> {:ok, 0}
schedule -> {:ok, cancel_count(schedule)}
nil -> {:ok, :no_schedule}
schedule -> {:ok, cancel_result(schedule)}
end
end)

count
result
end

defp cancel_count(schedule) do
defp cancel_result(schedule) do
case cancel(schedule) do
{:ok, _} -> 1
{:error, _} -> 0
{:ok, _} -> :ok
{:error, _} -> :no_schedule
end
end

defp active_schedule_for(team_id) do
team_id
|> active_schedule_query()
|> lock("FOR UPDATE")
|> Repo.one()
end

defp active_schedule_query(team_id) do
TeamDeletionSchedule
|> where([sch], sch.team_id == ^team_id)
|> where([sch], sch.status in ^TeamDeletionSchedule.active_statuses())
|> lock("FOR UPDATE")
|> Repo.one()
end

defp eligible_category?(today) do
Expand Down
3 changes: 3 additions & 0 deletions lib/plausible/teams/team.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ defmodule Plausible.Teams.Team do
has_one :subscription, Plausible.Billing.Subscription
has_one :enterprise_plan, Plausible.Billing.EnterprisePlan

has_one :team_deletion_schedule, Plausible.TeamDeletionSchedule,
where: [status: {:in, Plausible.TeamDeletionSchedule.active_statuses()}]

on_ee do
has_one :sso_integration, Plausible.Auth.SSO.Integration
end
Expand Down
2 changes: 1 addition & 1 deletion lib/workers/execute_team_deletions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ defmodule Plausible.Workers.ExecuteTeamDeletions do
for schedule <- TeamDeletionSchedules.due_for_deletion(today) do
team = schedule.team

if TeamDeletionSchedules.cancel_for_team(team) == 0 do
if TeamDeletionSchedules.cancel_for_team(team) == :no_schedule do
execute(schedule, team)
end
end
Expand Down
Loading
Loading