diff --git a/backend/assets/tailwind.config.js b/backend/assets/tailwind.config.js index 8fd2ef318..31ac4a661 100644 --- a/backend/assets/tailwind.config.js +++ b/backend/assets/tailwind.config.js @@ -14,7 +14,7 @@ module.exports = { ], safelist: [ { - pattern: /(bg|text)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-.*/, + pattern: /(bg|text|border|from|via|to)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-.*/, }, ], theme: { diff --git a/backend/config/config.exs b/backend/config/config.exs index 60c276a27..a3cacdabd 100644 --- a/backend/config/config.exs +++ b/backend/config/config.exs @@ -30,10 +30,10 @@ config :azimutt, free_plan_seats: 3, # MUST stay in sync with frontend/src/Conf.elm (`features`) free_plan_layouts: 3, - free_plan_memos: 5, + free_plan_memos: 3, free_plan_groups: 1, free_plan_colors: false, - free_plan_private_links: true, + free_plan_private_links: false, free_plan_sql_export: false, free_plan_db_analysis: false, free_plan_db_access: false, diff --git a/backend/lib/azimutt/organizations.ex b/backend/lib/azimutt/organizations.ex index 3ad2cb2a6..c64d2916b 100644 --- a/backend/lib/azimutt/organizations.ex +++ b/backend/lib/azimutt/organizations.ex @@ -14,6 +14,7 @@ defmodule Azimutt.Organizations do alias Azimutt.Projects.ProjectToken alias Azimutt.Repo alias Azimutt.Services.StripeSrv + alias Azimutt.Tracking alias Azimutt.Utils.Enumx alias Azimutt.Utils.Result @@ -329,7 +330,7 @@ defmodule Azimutt.Organizations do |> Repo.update() end - def get_organization_plan(%Organization{} = organization) do + def get_organization_plan(%Organization{} = organization, maybe_current_user) do plans = Azimutt.config(:instance_plans) || ["free"] cond do @@ -338,7 +339,7 @@ defmodule Azimutt.Organizations do organization.stripe_subscription_id && StripeSrv.stripe_configured?() -> stripe_plan(plans, organization.stripe_subscription_id) true -> default_plan(plans) end - |> Result.map(fn plan -> plan_overrides(plans, organization, plan) end) + |> Result.map(fn plan -> plan_overrides(plans, organization, plan, maybe_current_user) end) end defp clever_cloud_plan(plans, %CleverCloud.Resource{} = resource) do @@ -378,7 +379,7 @@ defmodule Azimutt.Organizations do end end - defp plan_overrides(plans, %Organization{} = organization, %OrganizationPlan{} = plan) do + defp plan_overrides(plans, %Organization{} = organization, %OrganizationPlan{} = plan, maybe_current_user) do if organization.data != nil && plans |> Enum.member?("pro") do plan |> override_layouts(organization.data) @@ -389,6 +390,7 @@ defmodule Azimutt.Organizations do else plan end + |> override_streak(maybe_current_user) end defp override_layouts(%OrganizationPlan{} = plan, %Organization.Data{} = data) do @@ -431,6 +433,22 @@ defmodule Azimutt.Organizations do end end + defp override_streak(%OrganizationPlan{} = plan, %User{} = maybe_current_user) do + # MUST stay sync with backend/lib/azimutt_web/templates/partials/_streak.html.heex + streak = Tracking.get_streak(maybe_current_user) |> Result.or_else(0) + plan = %{plan | streak: streak} + plan = if(streak >= 4, do: %{plan | colors: true}, else: plan) + plan = if(streak >= 6, do: %{plan | memos: nil}, else: plan) + plan = if(streak >= 10, do: %{plan | layouts: nil}, else: plan) + plan = if(streak >= 15, do: %{plan | groups: nil}, else: plan) + plan = if(streak >= 25, do: %{plan | sql_export: true}, else: plan) + plan = if(streak >= 40, do: %{plan | db_analysis: true}, else: plan) + plan = if(streak >= 60, do: %{plan | private_links: true}, else: plan) + plan + end + + defp override_streak(%OrganizationPlan{} = plan, maybe_current_user) when is_nil(maybe_current_user), do: plan + defp best_limit(a, b) do cond do a == nil || b == nil -> nil diff --git a/backend/lib/azimutt/organizations/organization_plan.ex b/backend/lib/azimutt/organizations/organization_plan.ex index 9a65e5024..f34b865ff 100644 --- a/backend/lib/azimutt/organizations/organization_plan.ex +++ b/backend/lib/azimutt/organizations/organization_plan.ex @@ -3,7 +3,7 @@ defmodule Azimutt.Organizations.OrganizationPlan do use TypedStruct alias Azimutt.Organizations.OrganizationPlan - # MUST stay in sync with frontend/src/Models/Plan.elm + # MUST stay in sync with frontend/ts-src/types/organization.ts & frontend/src/Models/Plan.elm typedstruct enforce: true do @derive Jason.Encoder field :id, atom() @@ -16,6 +16,7 @@ defmodule Azimutt.Organizations.OrganizationPlan do field :sql_export, boolean() field :db_analysis, boolean() field :db_access, boolean() + field :streak, integer() end def free do @@ -30,7 +31,8 @@ defmodule Azimutt.Organizations.OrganizationPlan do private_links: Azimutt.config(:free_plan_private_links), sql_export: Azimutt.config(:free_plan_sql_export), db_analysis: Azimutt.config(:free_plan_db_analysis), - db_access: Azimutt.config(:free_plan_db_access) + db_access: Azimutt.config(:free_plan_db_access), + streak: 0 } end @@ -45,7 +47,8 @@ defmodule Azimutt.Organizations.OrganizationPlan do private_links: true, sql_export: true, db_analysis: true, - db_access: true + db_access: true, + streak: 0 } end end diff --git a/backend/lib/azimutt/projects.ex b/backend/lib/azimutt/projects.ex index 9fc5fbecf..a0e322363 100644 --- a/backend/lib/azimutt/projects.ex +++ b/backend/lib/azimutt/projects.ex @@ -197,7 +197,7 @@ defmodule Azimutt.Projects do end) end - def access_project(project_id, token_id, now) do + def access_project(project_id, token_id, maybe_current_user, now) do ProjectToken |> where( [pt], @@ -210,14 +210,14 @@ defmodule Azimutt.Projects do |> where([p, _, _], p.id == ^project_id and p.storage_kind == :remote) |> Repo.one() |> Result.from_nillable() - |> Result.filter(fn p -> Organizations.get_organization_plan(p.organization) |> Result.exists(& &1.private_links) end, :not_found) + |> Result.filter(fn p -> Organizations.get_organization_plan(p.organization, maybe_current_user) |> Result.exists(& &1.private_links) end, :not_found) |> Result.tap(fn _ -> token |> ProjectToken.access_changeset(now) |> Repo.update() end) end) end def load_project(project_id, maybe_current_user, token_id, now) do if token_id do - access_project(project_id, token_id, now) + access_project(project_id, token_id, maybe_current_user, now) else get_project(project_id, maybe_current_user) end diff --git a/backend/lib/azimutt/tracking.ex b/backend/lib/azimutt/tracking.ex index 438d7cca2..7925688df 100644 --- a/backend/lib/azimutt/tracking.ex +++ b/backend/lib/azimutt/tracking.ex @@ -13,6 +13,27 @@ defmodule Azimutt.Tracking do alias Azimutt.Utils.Nil alias Azimutt.Utils.Result + def get_streak(%User{} = current_user) do + now = DateTime.utc_now() + months_ago = Timex.shift(now, months: -4) + + Event + |> where([e], e.created_by_id == ^current_user.id and e.created_at >= ^months_ago) + |> select([e], {fragment("to_char(?, 'yyyy-mm-dd')", e.created_at), count(e.id, :distinct)}) + |> group_by([e], fragment("to_char(?, 'yyyy-mm-dd')", e.created_at)) + |> Repo.all() + |> Result.from_nillable() + |> Result.map(fn res -> compute_streak(Map.new(res), now, 0) end) + end + + defp compute_streak(activity, now, streak) do + if Map.has_key?(activity, Date.to_string(now)) do + compute_streak(activity, Timex.shift(now, days: -1), streak + 1) + else + streak + end + end + def last_used_project(%User{} = current_user) do Event |> where( @@ -99,6 +120,9 @@ defmodule Azimutt.Tracking do def user_onboarding(%User{} = current_user, step, data), do: create_event("user_onboarding", user_data(current_user), data |> Map.put("step", step), current_user, nil, nil) + def organization_loaded(%User{} = current_user, %Organization{} = org), + do: create_event("organization_loaded", org_data(org), nil, current_user, org.id, nil) + def project_created(%User{} = current_user, %Project{} = project), do: create_event("project_created", project_data(project), nil, current_user, project.organization.id, project.id) diff --git a/backend/lib/azimutt_web/admin/organization/organization_controller.ex b/backend/lib/azimutt_web/admin/organization/organization_controller.ex index b7d45b5cc..bbec508fd 100644 --- a/backend/lib/azimutt_web/admin/organization/organization_controller.ex +++ b/backend/lib/azimutt_web/admin/organization/organization_controller.ex @@ -20,7 +20,7 @@ defmodule AzimuttWeb.Admin.OrganizationController do {:ok, start_stats} = "2022-11-01" |> Timex.parse("{YYYY}-{0M}-{0D}") with {:ok, %Organization{} = organization} <- Admin.get_organization(organization_id), - {:ok, %OrganizationPlan{} = plan} <- Organizations.get_organization_plan(organization) do + {:ok, %OrganizationPlan{} = plan} <- Organizations.get_organization_plan(organization, nil) do conn |> render("show.html", now: now, diff --git a/backend/lib/azimutt_web/controllers/api/organization_controller.ex b/backend/lib/azimutt_web/controllers/api/organization_controller.ex index 990031837..fdeb7adf0 100644 --- a/backend/lib/azimutt_web/controllers/api/organization_controller.ex +++ b/backend/lib/azimutt_web/controllers/api/organization_controller.ex @@ -13,7 +13,7 @@ defmodule AzimuttWeb.Api.OrganizationController do current_user = conn.assigns.current_user ctx = CtxParams.from_params(params) organizations = Organizations.list_organizations(current_user) - conn |> render("index.json", organizations: organizations, ctx: ctx) + conn |> render("index.json", organizations: organizations, current_user: current_user, ctx: ctx) end def table_colors(conn, %{"organization_organization_id" => organization_id, "tweet_url" => tweet_url}) do diff --git a/backend/lib/azimutt_web/controllers/api/project_controller.ex b/backend/lib/azimutt_web/controllers/api/project_controller.ex index cb9777bbe..96acdad88 100644 --- a/backend/lib/azimutt_web/controllers/api/project_controller.ex +++ b/backend/lib/azimutt_web/controllers/api/project_controller.ex @@ -23,7 +23,7 @@ defmodule AzimuttWeb.Api.ProjectController do current_user = conn.assigns.current_user with {:ok, %Organization{} = organization} <- Organizations.get_organization(organization_id, current_user), - do: conn |> render("index.json", projects: organization.projects) + do: conn |> render("index.json", projects: organization.projects, current_user: current_user) end def show(conn, %{"organization_id" => _organization_id, "project_id" => project_id} = params) do @@ -32,7 +32,7 @@ defmodule AzimuttWeb.Api.ProjectController do ctx = CtxParams.from_params(params) with {:ok, %Project{} = project} <- Projects.load_project(project_id, maybe_current_user, params["token"], now), - do: conn |> render("show.json", project: project, ctx: ctx) + do: conn |> render("show.json", project: project, maybe_current_user: maybe_current_user, ctx: ctx) end swagger_path :create do diff --git a/backend/lib/azimutt_web/controllers/organization_billing_controller.ex b/backend/lib/azimutt_web/controllers/organization_billing_controller.ex index e28b480a7..6a5b5c909 100644 --- a/backend/lib/azimutt_web/controllers/organization_billing_controller.ex +++ b/backend/lib/azimutt_web/controllers/organization_billing_controller.ex @@ -2,6 +2,7 @@ defmodule AzimuttWeb.OrganizationBillingController do use AzimuttWeb, :controller require Logger alias Azimutt.Accounts + alias Azimutt.Accounts.User alias Azimutt.CleverCloud alias Azimutt.Heroku alias Azimutt.Organizations @@ -28,42 +29,42 @@ defmodule AzimuttWeb.OrganizationBillingController do cond do organization.clever_cloud_resource -> conn |> redirect(external: CleverCloud.app_addons_url()) organization.heroku_resource -> conn |> redirect(external: Heroku.app_addons_url(organization.heroku_resource.app)) - organization.stripe_subscription_id -> conn |> stripe_subscription_view(organization) - true -> generate_billing_view(conn, "subscribe.html", organization, "You haven't got subscribe yet !") + organization.stripe_subscription_id -> conn |> stripe_subscription_view(organization, current_user) + true -> generate_billing_view(conn, "subscribe.html", organization, current_user, "You haven't got subscribe yet !") end end end - defp stripe_subscription_view(conn, %Organization{} = organization) do + defp stripe_subscription_view(conn, %Organization{} = organization, %User{} = current_user) do case Organizations.get_subscription_status(organization.stripe_subscription_id) do {:ok, :active} -> - generate_billing_view(conn, "billing.html", organization, "Your subscription is active !") + generate_billing_view(conn, "billing.html", organization, current_user, "Your subscription is active !") {:ok, :past_due} -> - generate_billing_view(conn, "billing.html", organization, "We have an issue with your subscription") + generate_billing_view(conn, "billing.html", organization, current_user, "We have an issue with your subscription") {:ok, :unpaid} -> - generate_billing_view(conn, "billing.html", organization, "We have an issue with your subscription") + generate_billing_view(conn, "billing.html", organization, current_user, "We have an issue with your subscription") {:ok, :canceled} -> - generate_billing_view(conn, "subscribe.html", organization, "Your subscription is canceled") + generate_billing_view(conn, "subscribe.html", organization, current_user, "Your subscription is canceled") {:ok, :incomplete} -> - generate_billing_view(conn, "billing.html", organization, "We have an issue with your subscription") + generate_billing_view(conn, "billing.html", organization, current_user, "We have an issue with your subscription") {:ok, :incomplete_expired} -> - generate_billing_view(conn, "billing.html", organization, "We have an issue with your subscription") + generate_billing_view(conn, "billing.html", organization, current_user, "We have an issue with your subscription") {:ok, :trialing} -> - generate_billing_view(conn, "billing.html", organization, "You are in free trial") + generate_billing_view(conn, "billing.html", organization, current_user, "You are in free trial") {:error, err} -> conn |> put_flash(:error, "Can't show view: #{err}.") |> redirect(to: Routes.organization_path(conn, :show, organization)) end end - defp generate_billing_view(conn, file, organization, message) do - with {:ok, plan} <- Organizations.get_organization_plan(organization) do + defp generate_billing_view(conn, file, organization, %User{} = current_user, message) do + with {:ok, plan} <- Organizations.get_organization_plan(organization, current_user) do conn |> put_view(AzimuttWeb.OrganizationView) |> render(file, organization: organization, plan: plan, message: message) diff --git a/backend/lib/azimutt_web/controllers/organization_controller.ex b/backend/lib/azimutt_web/controllers/organization_controller.ex index cb93471f8..afe44aba1 100644 --- a/backend/lib/azimutt_web/controllers/organization_controller.ex +++ b/backend/lib/azimutt_web/controllers/organization_controller.ex @@ -43,9 +43,10 @@ defmodule AzimuttWeb.OrganizationController do end with {:ok, organization} <- Organizations.get_organization(organization_id, current_user), + {:ok, _event} = Tracking.organization_loaded(current_user, organization), projects = Projects.list_projects(organization, current_user), {:ok, organization_events} = Tracking.recent_organization_events(organization), - {:ok, plan} <- Organizations.get_organization_plan(organization), + {:ok, plan} <- Organizations.get_organization_plan(organization, current_user), do: render(conn, "show.html", organization: organization, projects: projects, plan: plan, organization_events: organization_events) end @@ -58,7 +59,7 @@ defmodule AzimuttWeb.OrganizationController do end with {:ok, organization} <- Organizations.get_organization(organization_id, current_user), - {:ok, plan} <- Organizations.get_organization_plan(organization) do + {:ok, plan} <- Organizations.get_organization_plan(organization, current_user) do changeset = Organization.update_changeset(organization, %{}, current_user) render(conn, "edit.html", organization: organization, plan: plan, changeset: changeset) end @@ -75,7 +76,7 @@ defmodule AzimuttWeb.OrganizationController do |> redirect(to: Routes.organization_path(conn, :show, organization)) {:error, %Ecto.Changeset{} = changeset} -> - with {:ok, plan} <- Organizations.get_organization_plan(organization), + with {:ok, plan} <- Organizations.get_organization_plan(organization, current_user), do: render(conn, "edit.html", organization: organization, plan: plan, changeset: changeset) end end diff --git a/backend/lib/azimutt_web/controllers/organization_invitation_controller.ex b/backend/lib/azimutt_web/controllers/organization_invitation_controller.ex index b0e05f6bd..57fedd262 100644 --- a/backend/lib/azimutt_web/controllers/organization_invitation_controller.ex +++ b/backend/lib/azimutt_web/controllers/organization_invitation_controller.ex @@ -7,10 +7,11 @@ defmodule AzimuttWeb.OrganizationInvitationController do def show(conn, %{"invitation_id" => invitation_id}) do now = DateTime.utc_now() + current_user = conn.assigns.current_user # FIXME: remove `get_organization_plan` {:ok, invitation} = Organizations.get_organization_invitation(invitation_id) organization = invitation.organization - {:ok, plan} = Organizations.get_organization_plan(organization) + {:ok, plan} = Organizations.get_organization_plan(organization, current_user) render(conn, "show.html", now: now, diff --git a/backend/lib/azimutt_web/controllers/organization_member_controller.ex b/backend/lib/azimutt_web/controllers/organization_member_controller.ex index af5f05b4e..7651e1406 100644 --- a/backend/lib/azimutt_web/controllers/organization_member_controller.ex +++ b/backend/lib/azimutt_web/controllers/organization_member_controller.ex @@ -1,6 +1,7 @@ defmodule AzimuttWeb.OrganizationMemberController do use AzimuttWeb, :controller alias Azimutt.Accounts + alias Azimutt.Accounts.User alias Azimutt.Organizations alias Azimutt.Organizations.Organization alias Azimutt.Organizations.OrganizationInvitation @@ -20,7 +21,7 @@ defmodule AzimuttWeb.OrganizationMemberController do with {:ok, %Organization{} = organization} <- Organizations.get_organization(organization_id, current_user) do organization_invitation_changeset = OrganizationInvitation.create_changeset(%OrganizationInvitation{}, %{}, organization.id, current_user, now) - render_index(conn, organization, organization_invitation_changeset) + render_index(conn, organization, current_user, organization_invitation_changeset) end end @@ -45,7 +46,7 @@ defmodule AzimuttWeb.OrganizationMemberController do |> redirect(to: Routes.organization_member_path(conn, :index, invitation.organization_id)) {:error, %Ecto.Changeset{} = changeset} -> - render_index(conn, organization, changeset) + render_index(conn, organization, current_user, changeset) end end @@ -90,8 +91,8 @@ defmodule AzimuttWeb.OrganizationMemberController do end end - defp render_index(conn, organization, changeset) do - with {:ok, plan} <- Organizations.get_organization_plan(organization) do + defp render_index(conn, organization, %User{} = current_user, changeset) do + with {:ok, plan} <- Organizations.get_organization_plan(organization, current_user) do # FIXME: create a `Organizations.get_pending_invitations(organization.id)` organization_invitations = organization.invitations diff --git a/backend/lib/azimutt_web/templates/layout/_organization_right_bar.html.heex b/backend/lib/azimutt_web/templates/layout/_organization_right_bar.html.heex index 436736840..9158a89c5 100644 --- a/backend/lib/azimutt_web/templates/layout/_organization_right_bar.html.heex +++ b/backend/lib/azimutt_web/templates/layout/_organization_right_bar.html.heex @@ -1,24 +1,4 @@ -
-
-
-

Activities

-
    - <%= for event <- @organization_events do %> -
  • -
    -
    -
    - -

    - - <%= AzimuttWeb.OrganizationView.generate_html_event_description(event) %> - -

    - -
  • - <% end %> -
- -
-
+
+ <%= render AzimuttWeb.PartialsView, "_streak.html", value: @plan.streak %> + <%= render AzimuttWeb.PartialsView, "_activity_feed.html", events: @organization_events %>
diff --git a/backend/lib/azimutt_web/templates/partials/_activity_feed.html.heex b/backend/lib/azimutt_web/templates/partials/_activity_feed.html.heex new file mode 100644 index 000000000..49ce81d5b --- /dev/null +++ b/backend/lib/azimutt_web/templates/partials/_activity_feed.html.heex @@ -0,0 +1,17 @@ +
+

Activities

+ +
diff --git a/backend/lib/azimutt_web/templates/partials/_streak.html.heex b/backend/lib/azimutt_web/templates/partials/_streak.html.heex new file mode 100644 index 000000000..a8baed085 --- /dev/null +++ b/backend/lib/azimutt_web/templates/partials/_streak.html.heex @@ -0,0 +1,56 @@ +<%= if @value > 1 do %> +
+
You're on a
+ <% emojis = "πŸ’―β˜€οΈβ­οΈπŸŒŸβœ¨βš‘οΈπŸ‘πŸ‘πŸ‘ŒβœŒοΈπŸ’ͺπŸ₯‰πŸ₯ˆπŸ₯‡πŸ…πŸ†πŸ₯πŸ“πŸ¦…πŸ¦–πŸ‰πŸ¦£πŸ¦¬πŸ˜πŸ΄πŸ¦„πŸ”ŽπŸ”¬πŸ”­πŸš€πŸ›ΈπŸ§šπŸ§œπŸ§žπŸ§ŸπŸ§ŒπŸͺ΅πŸ›–πŸ οΈπŸ˜οΈπŸ°πŸ§‘β€πŸŽ“πŸ₯·πŸ€΅πŸ§›πŸ§‘β€πŸŽ€πŸŽˆπŸŽŠπŸŽ‰πŸ₯³πŸŽ†πŸ¬πŸ­πŸͺπŸ©πŸŽ‚πŸ§‘β€πŸ«πŸ§‘β€πŸ³πŸ§‘β€πŸŽ¨πŸ§‘β€πŸ’»πŸ«…πŸ€©πŸ˜πŸ˜˜πŸ₯°β€οΈπŸ§‘πŸ’›πŸ’šπŸ’™πŸ’œπŸ’—πŸ’•πŸ’“πŸ’πŸ’–πŸ₯–πŸ₯πŸ₯―πŸ₯žπŸ”πŸ·πŸΉπŸ»πŸ₯‚πŸΎπŸ˜€πŸ˜„πŸ˜†πŸ˜πŸ˜‡πŸ€”πŸ§πŸ₯ΈπŸ€“πŸ˜ŽπŸŒˆπŸ’ŽπŸ‘‘πŸ§­" %> + <% emoji = String.at(emojis, rem(@value, String.length emojis)) %> +
<%= @value %> day streak <%= emoji %>
+
    + <%= render "_streak_step.html", step: 1, value: @value, color: "fuchsia", color_next: "indigo" %> + <%= render "_streak_step.html", step: 2, value: @value, color: "indigo", color_next: "sky" %> + <%= render "_streak_step.html", step: 3, value: @value, color: "sky", color_next: "green" %> + <%= if @value <= 6 do %> + <%= render "_streak_step.html", step: 4, value: @value, color: "green", color_next: "yellow", reward: %{icon: "🎨", label: "Day 4: unlock table colors"} %> + <% else %> + <%= render "_streak_step.html", step: 4, value: @value, color: "green", color_next: "yellow" %> + <% end %> + <%= render "_streak_step.html", step: 5, value: @value, color: "yellow", color_next: "red" %> + <%= cond do %> + <% @value <= 6 -> %> + <%= render "_streak_step.html", step: 6, value: @value, color: "red", reward: %{icon: "πŸ“", label: "Day 6: unlimited memos"} %> + <% @value <= 10 -> %> + <%= render "_streak_step.html", step: 10, value: @value, color: "red", reward: %{icon: "πŸ—‚οΈ", label: "Day 10: unlimited layouts"} %> + <% @value <= 15 -> %> + <%= render "_streak_step.html", step: 15, value: @value, color: "red", reward: %{icon: "🏘️", label: "Day 15: unlimited groups"} %> + <% @value <= 25 -> %> + <%= render "_streak_step.html", step: 25, value: @value, color: "red", reward: %{icon: "πŸͺ„", label: "Day 25: unlock SQL export"} %> + <% @value <= 40 -> %> + <%= render "_streak_step.html", step: 40, value: @value, color: "red", reward: %{icon: "πŸ§ͺ", label: "Day 40: unlock schema analysis"} %> + <% @value <= 60 -> %> + <%= render "_streak_step.html", step: 60, value: @value, color: "red", reward: %{icon: "πŸ•΅οΈ", label: "Day 60: unlock private links"} %> + <% @value <= 99 -> %> + <%= render "_streak_step.html", step: @value, value: @value, color: "red", reward: %{icon: "πŸ”₯", label: "Day #{@value}"} %> + <% true -> %> + <%= render "_streak_step.html", step: @value, value: @value, color: "red", reward: %{icon: "🧭", label: "Day #{@value}"} %> + <% end %> +
+ <% # MUST stay sync with backend/lib/azimutt/organizations.ex:436#override_streak %> + <%= cond do %> + <% @value >= 100 -> %>

WoW, you are serious 🀩
Contact us, we have a gift for you πŸ₯³

+ <% @value > 60 -> %>

Your current streak give you table colors, unlimited memos, layouts and groups, SQL exports, schema analysis and private links.

+ <% @value == 60 -> %>

Well done! You just unlocked private links!

+ <% @value > 40 -> %>

Your current streak give you table colors, unlimited memos, layouts and groups, SQL exports and schema analysis.

+ <% @value == 40 -> %>

Well done! You just unlocked schema analysis!

+ <% @value > 25 -> %>

Your current streak give you table colors, unlimited memos, layouts and groups and SQL exports.

+ <% @value == 25 -> %>

Well done! You just unlocked SQL export!

+ <% @value > 15 -> %>

Your current streak give you table colors and unlimited memos, layouts and groups.

+ <% @value == 15 -> %>

Well done! You just unlocked unlimited groups!

+ <% @value > 10 -> %>

Your current streak give you table colors, unlimited memos and layouts.

+ <% @value == 10 -> %>

Well done! You just unlocked unlimited layouts!

+ <% @value > 6 -> %>

Your current streak give you table colors and unlimited memos.

+ <% @value == 6 -> %>

Well done! You just unlocked unlimited memos!

+ <% @value > 4 -> %>

Your current streak give you table colors.

+ <% @value == 4 -> %>

Well done! You just unlocked table colors!

+ <% true -> %> + <% end %> +
+<% end %> diff --git a/backend/lib/azimutt_web/templates/partials/_streak_step.html.heex b/backend/lib/azimutt_web/templates/partials/_streak_step.html.heex new file mode 100644 index 000000000..9abb40702 --- /dev/null +++ b/backend/lib/azimutt_web/templates/partials/_streak_step.html.heex @@ -0,0 +1,21 @@ +
  • + <%= if assigns[:color_next] do %> + + <% end %> +
    + <%= cond do %> + <% assigns[:reward] -> %> + <%= @reward.icon %> + <% @step < @value -> %> + + <% @step == @value -> %> + + <% true -> %> + <% end %> + Day <%= @step %> +
    +
  • diff --git a/backend/lib/azimutt_web/views/api/organization_view.ex b/backend/lib/azimutt_web/views/api/organization_view.ex index 4a3e4eb36..870f87e6f 100644 --- a/backend/lib/azimutt_web/views/api/organization_view.ex +++ b/backend/lib/azimutt_web/views/api/organization_view.ex @@ -1,15 +1,16 @@ defmodule AzimuttWeb.Api.OrganizationView do use AzimuttWeb, :view + alias Azimutt.Accounts.User alias Azimutt.Organizations alias Azimutt.Organizations.Organization alias AzimuttWeb.Utils.CtxParams # FIXME: how to pattern match on list of organizations? - def render("index.json", %{organizations: organizations, ctx: %CtxParams{} = ctx}) do - render_many(organizations, __MODULE__, "show.json", ctx: ctx) + def render("index.json", %{organizations: organizations, current_user: %User{} = current_user, ctx: %CtxParams{} = ctx}) do + render_many(organizations, __MODULE__, "show.json", maybe_current_user: current_user, ctx: ctx) end - def render("show.json", %{organization: %Organization{} = organization, ctx: %CtxParams{} = ctx}) do + def render("show.json", %{organization: %Organization{} = organization, maybe_current_user: maybe_current_user, ctx: %CtxParams{} = ctx}) do %{ id: organization.id, slug: organization.slug, @@ -17,8 +18,8 @@ defmodule AzimuttWeb.Api.OrganizationView do logo: organization.logo, description: organization.description } - |> put_plan(organization, ctx) - |> put_projects(organization, ctx) + |> put_plan(organization, maybe_current_user, ctx) + |> put_projects(organization, maybe_current_user, ctx) |> put_clever_cloud_resource(organization, ctx) |> put_heroku_resource(organization, ctx) end @@ -30,19 +31,19 @@ defmodule AzimuttWeb.Api.OrganizationView do } end - defp put_plan(json, %Organization{} = organization, %CtxParams{} = ctx) do + defp put_plan(json, %Organization{} = organization, maybe_current_user, %CtxParams{} = ctx) do if ctx.expand |> Enum.member?("plan") do - {:ok, plan} = Organizations.get_organization_plan(organization) + {:ok, plan} = Organizations.get_organization_plan(organization, maybe_current_user) json |> Map.put(:plan, plan) else json end end - defp put_projects(json, %Organization{} = organization, %CtxParams{} = ctx) do + defp put_projects(json, %Organization{} = organization, maybe_current_user, %CtxParams{} = ctx) do if ctx.expand |> Enum.member?("projects") do project_ctx = ctx |> CtxParams.nested("projects") - json |> Map.put(:projects, render_many(organization.projects, AzimuttWeb.Api.ProjectView, "show.json", ctx: project_ctx)) + json |> Map.put(:projects, render_many(organization.projects, AzimuttWeb.Api.ProjectView, "show.json", maybe_current_user: maybe_current_user, ctx: project_ctx)) else json end diff --git a/backend/lib/azimutt_web/views/api/project_view.ex b/backend/lib/azimutt_web/views/api/project_view.ex index 409aa9fa4..fb1980ba1 100644 --- a/backend/lib/azimutt_web/views/api/project_view.ex +++ b/backend/lib/azimutt_web/views/api/project_view.ex @@ -1,15 +1,16 @@ defmodule AzimuttWeb.Api.ProjectView do use AzimuttWeb, :view + alias Azimutt.Accounts.User alias Azimutt.Projects alias Azimutt.Projects.Project alias Azimutt.Utils.Result alias AzimuttWeb.Utils.CtxParams - def render("index.json", %{projects: projects}) do - render_many(projects, __MODULE__, "show.json", ctx: CtxParams.empty()) + def render("index.json", %{projects: projects, current_user: %User{} = current_user}) do + render_many(projects, __MODULE__, "show.json", maybe_current_user: current_user, ctx: CtxParams.empty()) end - def render("show.json", %{project: %Project{} = project, ctx: %CtxParams{} = ctx}) do + def render("show.json", %{project: %Project{} = project, maybe_current_user: maybe_current_user, ctx: %CtxParams{} = ctx}) do %{ id: project.id, slug: project.slug, @@ -32,14 +33,14 @@ defmodule AzimuttWeb.Api.ProjectView do updated_at: project.updated_at, archived_at: project.archived_at } - |> put_orga(project, ctx) + |> put_orga(project, maybe_current_user, ctx) |> put_content(project, ctx) end - defp put_orga(json, %Project{} = project, %CtxParams{} = ctx) do + defp put_orga(json, %Project{} = project, maybe_current_user, %CtxParams{} = ctx) do if ctx.expand |> Enum.member?("organization") do orga_ctx = ctx |> CtxParams.nested("organization") - json |> Map.put(:organization, render_one(project.organization, AzimuttWeb.Api.OrganizationView, "show.json", ctx: orga_ctx)) + json |> Map.put(:organization, render_one(project.organization, AzimuttWeb.Api.OrganizationView, "show.json", maybe_current_user: maybe_current_user, ctx: orga_ctx)) else json end diff --git a/backend/test/azimutt/projects_test.exs b/backend/test/azimutt/projects_test.exs index 8a00d5c43..0f2807453 100644 --- a/backend/test/azimutt/projects_test.exs +++ b/backend/test/azimutt/projects_test.exs @@ -122,7 +122,9 @@ defmodule Azimutt.ProjectsTest do end describe "project_tokens" do + @tag :skip test "can create, list, access and revoke tokens" do + # TODO: needs organization to have pro plan, add function to set it now = DateTime.utc_now() user = user_fixture() user2 = user_fixture() @@ -135,7 +137,7 @@ defmodule Azimutt.ProjectsTest do {:ok, token} = Projects.create_project_token(project_id, user, %{"name" => "Token"}) assert {:ok, ["Token"]} = Projects.list_project_tokens(project_id, user, now) |> Result.map(fn ts -> ts |> Enum.map(& &1.name) end) - assert {:ok, project_id} = Projects.access_project(project_id, token.id, now) |> Result.map(& &1.id) + assert {:ok, project_id} = Projects.access_project(project_id, token.id, user, now) |> Result.map(& &1.id) accessed_token = ProjectToken |> Azimutt.Repo.get(token.id) assert 1 = accessed_token.nb_access @@ -143,7 +145,7 @@ defmodule Azimutt.ProjectsTest do {:ok, _} = Projects.revoke_project_token(token.id, user, now) assert {:ok, []} = Projects.list_project_tokens(project_id, user, now) |> Result.map(fn ts -> ts |> Enum.map(& &1.name) end) - assert {:error, :not_found} = Projects.access_project(project_id, token.id, now) |> Result.map(& &1.id) + assert {:error, :not_found} = Projects.access_project(project_id, token.id, user, now) |> Result.map(& &1.id) end end end diff --git a/frontend/src/Conf.elm b/frontend/src/Conf.elm index 469fbbb28..a7ed29a66 100644 --- a/frontend/src/Conf.elm +++ b/frontend/src/Conf.elm @@ -124,10 +124,10 @@ features : Features features = -- MUST stay in sync with backend/config/config.exs (`free_plan_layouts`) { layouts = { name = "layouts", free = 3 } - , memos = { name = "memos", free = 5 } + , memos = { name = "memos", free = 3 } , groups = { name = "groups", free = 1 } , tableColor = { name = "table_color", free = False } - , privateLinks = { name = "private_links", free = True } + , privateLinks = { name = "private_links", free = False } , sqlExport = { name = "sql_export", free = False } , dbAnalysis = { name = "analysis", free = False } , dbAccess = { name = "data_access", free = False } diff --git a/frontend/src/Models/Plan.elm b/frontend/src/Models/Plan.elm index 8138ab97f..48769f840 100644 --- a/frontend/src/Models/Plan.elm +++ b/frontend/src/Models/Plan.elm @@ -8,7 +8,7 @@ import Libs.Json.Encode as Encode type alias Plan = - -- MUST stay in sync with backend/lib/azimutt/organizations/organization_plan.ex + -- MUST stay in sync with frontend/ts-src/types/organization.ts & backend/lib/azimutt/organizations/organization_plan.ex { id : String , name : String , layouts : Maybe Int @@ -19,6 +19,7 @@ type alias Plan = , sqlExport : Bool , dbAnalysis : Bool , dbAccess : Bool + , streak : Int } @@ -35,6 +36,7 @@ free = , sqlExport = Conf.features.sqlExport.free , dbAnalysis = Conf.features.dbAnalysis.free , dbAccess = Conf.features.dbAnalysis.free + , streak = 0 } @@ -51,6 +53,7 @@ full = , sqlExport = True , dbAnalysis = True , dbAccess = True + , streak = 0 } @@ -67,12 +70,13 @@ encode value = , ( "sql_export", value.sqlExport |> Encode.bool ) , ( "db_analysis", value.dbAnalysis |> Encode.bool ) , ( "db_access", value.dbAccess |> Encode.bool ) + , ( "streak", value.streak |> Encode.int ) ] decode : Decode.Decoder Plan decode = - Decode.map10 Plan + Decode.map11 Plan (Decode.field "id" Decode.string) (Decode.field "name" Decode.string) (Decode.maybeField "layouts" Decode.int) @@ -83,3 +87,4 @@ decode = (Decode.field "sql_export" Decode.bool) (Decode.field "db_analysis" Decode.bool) (Decode.field "db_access" Decode.bool) + (Decode.field "streak" Decode.int) diff --git a/frontend/tests/TestHelpers/OrganizationFuzzers.elm b/frontend/tests/TestHelpers/OrganizationFuzzers.elm index 4f6fa15a3..f43beb52b 100644 --- a/frontend/tests/TestHelpers/OrganizationFuzzers.elm +++ b/frontend/tests/TestHelpers/OrganizationFuzzers.elm @@ -34,7 +34,7 @@ organizationName = plan : Fuzzer Plan plan = - Fuzz.map10 Plan planId planName (Fuzz.maybe intPosSmall) (Fuzz.maybe intPosSmall) (Fuzz.maybe intPosSmall) Fuzz.bool Fuzz.bool Fuzz.bool Fuzz.bool Fuzz.bool + Fuzz.map11 Plan planId planName (Fuzz.maybe intPosSmall) (Fuzz.maybe intPosSmall) (Fuzz.maybe intPosSmall) Fuzz.bool Fuzz.bool Fuzz.bool Fuzz.bool Fuzz.bool Fuzz.int planId : Fuzzer String diff --git a/frontend/ts-src/types/organization.ts b/frontend/ts-src/types/organization.ts index d1116399c..70e96a9d7 100644 --- a/frontend/ts-src/types/organization.ts +++ b/frontend/ts-src/types/organization.ts @@ -13,6 +13,7 @@ export const OrganizationName = z.string() export type PlanId = 'free' | 'pro' export const PlanId = z.enum(['free', 'pro']) +// MUST stay in sync with frontend/src/Models/Plan.elm & backend/lib/azimutt/organizations/organization_plan.ex export interface Plan { id: PlanId name: string @@ -24,6 +25,7 @@ export interface Plan { sql_export: boolean db_analysis: boolean db_access: boolean + streak: number } export const Plan = z.object({ @@ -36,7 +38,8 @@ export const Plan = z.object({ private_links: z.boolean(), sql_export: z.boolean(), db_analysis: z.boolean(), - db_access: z.boolean() + db_access: z.boolean(), + streak: z.number(), }).strict() export interface Organization { diff --git a/frontend/ts-src/utils/constants.test.ts b/frontend/ts-src/utils/constants.test.ts index f4d305208..cc0f64aef 100644 --- a/frontend/ts-src/utils/constants.test.ts +++ b/frontend/ts-src/utils/constants.test.ts @@ -24,7 +24,8 @@ export const plan: Plan = { private_links: false, sql_export: false, db_analysis: false, - db_access: false + db_access: false, + streak: 0 } export const organization: Organization = { id: uuid,