Skip to content
Merged
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
1 change: 1 addition & 0 deletions app/ee/assets/js/admin_api/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export interface Company {
goalsCount?: number;
spacesCount?: number;
projectsCount?: number;
storageUsageBytes?: number;
lastActivityAt?: string;
insertedAt?: string;
uuid?: string;
Expand Down
5 changes: 3 additions & 2 deletions app/ee/assets/js/pages/SaasAdminCompanyPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as React from "react";
import { EnableFeatureModal } from "./EnableFeatureModal";
import { RemoveFeatureFlagsModal } from "./RemoveFeatureFlagsModal";

import { Avatar, IconFlare, IconTrash, SecondaryButton, FormattedTime } from "turboui";
import { Avatar, IconFlare, IconTrash, SecondaryButton, FormattedTime, formatStorageBytes } from "turboui";
import { useFormattedTimePreferences } from "@/hooks/useFormattedTimePreferences";

import { useStartSupportSession } from "@/features/SupportSessions";
Expand Down Expand Up @@ -89,11 +89,12 @@ function Info({ company }: { company: AdminApi.Company }) {
function StatsSection({ company }: { company: AdminApi.Company }) {
return (
<div className="border-y border-stroke-base py-3 mt-2">
<div className="grid grid-cols-4 gap-4 w-full">
<div className="grid grid-cols-5 gap-4 w-full">
<Stat title="People" value={company.peopleCount!} />
<Stat title="Spaces" value={company.spacesCount!} />
<Stat title="Goals" value={company.goalsCount!} />
<Stat title="Projects" value={company.projectsCount!} />
<Stat title="Storage" value={formatStorageBytes(company.storageUsageBytes)} />
</div>
</div>
);
Expand Down
8 changes: 6 additions & 2 deletions app/ee/assets/js/pages/SaasAdminPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
IconX,
Tabs,
Tooltip,
formatStorageBytes,
useTabs,
} from "turboui";

Expand Down Expand Up @@ -231,29 +232,32 @@ function ActiveCompanyList({ searchQuery }: { searchQuery: string }) {

function CompanyTable({ companies }: { companies: AdminApi.Company[] }) {
const formattedTimePreferences = useFormattedTimePreferences();
const gridTemplateColumns = "0.5fr 4fr 1fr 1fr 1fr 1fr 1fr 1fr 1.5fr 1.5fr";

return (
<div>
<TableRow header gridTemplateColumns="0.5fr 4fr 1fr 1fr 1fr 1fr 1fr 1.5fr 1.5fr">
<TableRow header gridTemplateColumns={gridTemplateColumns}>
<div>#</div>
<div>Company</div>
<div className="text-right">People</div>
<div className="text-right">Spaces</div>
<div className="text-right">Goals</div>
<div className="text-right">Projects</div>
<div className="text-right">Storage</div>
<div className="text-right">Owners</div>
<div className="text-right">Last Activity</div>
<div className="text-right">Created At</div>
</TableRow>

{companies.map((company, index) => (
<TableRow key={company.id} linkTo={`/admin/companies/${company.id}`} gridTemplateColumns="0.5fr 4fr 1fr 1fr 1fr 1fr 1fr 1.5fr 1.5fr">
<TableRow key={company.id} linkTo={`/admin/companies/${company.id}`} gridTemplateColumns={gridTemplateColumns}>
<div>{index + 1}</div>
<div>{company.name}</div>
<div className="text-right">{company.peopleCount}</div>
<div className="text-right">{company.spacesCount}</div>
<div className="text-right">{company.goalsCount}</div>
<div className="text-right">{company.projectsCount}</div>
<div className="text-right">{formatStorageBytes(company.storageUsageBytes)}</div>

<div className="flex justify-end -mt-0.5">
<AvatarList people={company.owners ?? []} size={20} maxElements={3} stacked />
Expand Down
2 changes: 2 additions & 0 deletions app/ee/lib/admin_api/queries/get_active_companies.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule OperatelyEE.AdminApi.Queries.GetActiveCompanies do
|> Company.load_people_count()
|> Company.load_goals_count()
|> Company.load_projects_count()
|> Company.load_storage_usage_bytes()
|> Company.load_last_activity_event()

# Filter based on our activity criteria
Expand Down Expand Up @@ -76,6 +77,7 @@ defmodule OperatelyEE.AdminApi.Queries.GetActiveCompanies do
goals_count: company.goals_count,
spaces_count: company.spaces_count,
projects_count: company.projects_count,
storage_usage_bytes: company.storage_usage_bytes,
owners: OperatelyWeb.Api.Serializer.serialize(company.owners, level: :full),
last_activity_at: OperatelyWeb.Api.Serializer.serialize(company.last_activity_at, level: :essential),
inserted_at: OperatelyWeb.Api.Serializer.serialize(company.inserted_at, level: :essential)
Expand Down
2 changes: 2 additions & 0 deletions app/ee/lib/admin_api/queries/get_companies.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ defmodule OperatelyEE.AdminApi.Queries.GetCompanies do
|> Company.load_goals_count()
|> Company.load_spaces_count()
|> Company.load_projects_count()
|> Company.load_storage_usage_bytes()
|> Company.load_last_activity_event()
end

Expand All @@ -39,6 +40,7 @@ defmodule OperatelyEE.AdminApi.Queries.GetCompanies do
goals_count: company.goals_count,
spaces_count: company.spaces_count,
projects_count: company.projects_count,
storage_usage_bytes: company.storage_usage_bytes,
owners: OperatelyWeb.Api.Serializer.serialize(company.owners, level: :full),
last_activity_at: OperatelyWeb.Api.Serializer.serialize(company.last_activity_at, level: :essential),
inserted_at: OperatelyWeb.Api.Serializer.serialize(company.inserted_at, level: :essential)
Expand Down
2 changes: 2 additions & 0 deletions app/ee/lib/admin_api/queries/get_company.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ defmodule OperatelyEE.AdminApi.Queries.GetCompany do
&Company.load_goals_count/1,
&Company.load_spaces_count/1,
&Company.load_projects_count/1,
&Company.load_storage_usage_bytes/1,
&Company.load_last_activity_event/1
]
])
Expand All @@ -42,6 +43,7 @@ defmodule OperatelyEE.AdminApi.Queries.GetCompany do
goals_count: company.goals_count,
spaces_count: company.spaces_count,
projects_count: company.projects_count,
storage_usage_bytes: company.storage_usage_bytes,
owners: OperatelyWeb.Api.Serializer.serialize(company.owners, level: :full),
last_activity_at: OperatelyWeb.Api.Serializer.serialize(company.last_activity_at, level: :essential),
uuid: company.id,
Expand Down
1 change: 1 addition & 0 deletions app/ee/lib/admin_api/types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule OperatelyEE.AdminApi.Types do
field? :goals_count, :integer
field? :spaces_count, :integer
field? :projects_count, :integer
field? :storage_usage_bytes, :integer
field? :last_activity_at, :datetime
field? :inserted_at, :datetime

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule OperatelyEE.AdminApi.Queries.GetActiveCompaniesTest do
use Operately.DataCase

import Operately.BlobsFixtures

alias OperatelyEE.AdminApi.Queries.GetActiveCompanies
alias Operately.Support.Factory

Expand Down Expand Up @@ -190,6 +192,7 @@ defmodule OperatelyEE.AdminApi.Queries.GetActiveCompaniesTest do
assert Map.has_key?(company_data, :goals_count)
assert Map.has_key?(company_data, :spaces_count)
assert Map.has_key?(company_data, :projects_count)
assert Map.has_key?(company_data, :storage_usage_bytes)
assert Map.has_key?(company_data, :owners)
assert Map.has_key?(company_data, :last_activity_at)
assert Map.has_key?(company_data, :inserted_at)
Expand All @@ -201,8 +204,34 @@ defmodule OperatelyEE.AdminApi.Queries.GetActiveCompaniesTest do
assert is_integer(company_data.goals_count)
assert is_integer(company_data.spaces_count)
assert is_integer(company_data.projects_count)
assert is_integer(company_data.storage_usage_bytes)
assert is_list(company_data.owners)
end

test "sums uploaded company blobs and ignores pending ones", ctx do
ctx =
ctx
|> Factory.setup()
|> Factory.add_space(:space)
|> Factory.add_company_member(:member1)
|> Factory.add_company_member(:member2)
|> Factory.add_goal(:goal1, :space, creator: :member1)
|> Factory.add_goal(:goal2, :space, creator: :member2)
|> Factory.add_project(:project1, :space, creator: :member1)
|> Factory.add_project(:project2, :space, creator: :member2)

create_recent_activity(ctx.company)

blob_fixture(%{company_id: ctx.company.id, author_id: ctx.creator.id, status: :uploaded, size: 1024})
blob_fixture(%{company_id: ctx.company.id, author_id: ctx.creator.id, status: :uploaded, size: 2048})
blob_fixture(%{company_id: ctx.company.id, author_id: ctx.creator.id, status: :pending, size: 4096})

{:ok, result} = GetActiveCompanies.call(nil, %{})

assert length(result.companies) == 1
company_data = hd(result.companies)
assert company_data.storage_usage_bytes == 3072
end
end

# Helper functions to create test data
Expand Down
55 changes: 55 additions & 0 deletions app/ee/test/operately_ee/admin_api/queries/get_companies_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
defmodule OperatelyEE.AdminApi.Queries.GetCompaniesTest do
use OperatelyWeb.TurboCase

import Operately.BlobsFixtures

alias Operately.People.Account
alias Operately.Support.Factory

describe "security" do
test "it requires authentication", ctx do
assert {401, "Unauthorized"} = admin_query(ctx.conn, :get_companies, %{})
end

test "it requires a site admin", ctx do
ctx = Factory.setup(ctx) |> Factory.log_in_account(:account)

assert {401, "Unauthorized"} = admin_query(ctx.conn, :get_companies, %{})
end
end

describe "functionality" do
setup ctx do
ctx = Factory.setup(ctx)
{:ok, _} = Account.promote_to_admin(ctx.account)

ctx
|> Map.put(:account, Repo.get!(Account, ctx.account.id))
|> Factory.log_in_account(:account)
end

test "returns companies from the database", ctx do
assert {200, %{companies: companies}} = admin_query(ctx.conn, :get_companies, %{})

assert Enum.any?(companies, &(&1.name == ctx.company.name))
end

test "serializes storage_usage_bytes as sum of uploaded company blobs", ctx do
blob_fixture(%{company_id: ctx.company.id, author_id: ctx.creator.id, status: :uploaded, size: 1024})
blob_fixture(%{company_id: ctx.company.id, author_id: ctx.creator.id, status: :uploaded, size: 2048})
blob_fixture(%{company_id: ctx.company.id, author_id: ctx.creator.id, status: :pending, size: 4096})

assert {200, %{companies: companies}} = admin_query(ctx.conn, :get_companies, %{})

company_data = Enum.find(companies, &(&1.name == ctx.company.name))
assert company_data.storage_usage_bytes == 3072
end

test "returns zero storage when company has no uploaded blobs", ctx do
assert {200, %{companies: companies}} = admin_query(ctx.conn, :get_companies, %{})

company_data = Enum.find(companies, &(&1.name == ctx.company.name))
assert company_data.storage_usage_bytes == 0
end
end
end
33 changes: 32 additions & 1 deletion app/lib/operately/companies/company.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ defmodule Operately.Companies.Company do
field :goals_count, :integer, virtual: true
field :spaces_count, :integer, virtual: true
field :projects_count, :integer, virtual: true
field :storage_usage_bytes, :integer, virtual: true
field :last_activity_at, :utc_datetime, virtual: true

timestamps()
Expand Down Expand Up @@ -248,6 +249,25 @@ defmodule Operately.Companies.Company do
[company] |> load_projects_count() |> hd()
end

def load_storage_usage_bytes(companies) when is_list(companies) do
query =
from(b in Operately.Blobs.Blob,
where: b.company_id in ^ids(companies) and b.purpose == :company_file and b.status == :uploaded,
group_by: b.company_id,
select: {b.company_id, sum(b.size)}
)

companies
|> load_aggregate(query, :storage_usage_bytes)
|> Enum.map(fn company ->
Map.update!(company, :storage_usage_bytes, &normalize_aggregate_sum/1)
end)
end

def load_storage_usage_bytes(company) do
[company] |> load_storage_usage_bytes() |> hd()
end

def load_last_activity_event(companies) when is_list(companies) do
ids = Enum.map(companies, fn c -> to_string(c.id) end)

Expand All @@ -257,7 +277,14 @@ defmodule Operately.Companies.Company do
group_by: fragment("?->> ?", a.content, "company_id"),
select: {fragment("?->>?", a.content, "company_id"), max(a.inserted_at)}

load_aggregate(companies, query, :last_activity_at, nil)
results = Operately.Repo.all(query)

Enum.map(companies, fn company ->
case Enum.find(results, fn {id, _} -> id == to_string(company.id) end) do
{_, last_activity_at} -> Map.put(company, :last_activity_at, last_activity_at)
nil -> Map.put(company, :last_activity_at, nil)
end
end)
end

def load_last_activity_event(company) do
Expand All @@ -275,6 +302,10 @@ defmodule Operately.Companies.Company do
end)
end

defp normalize_aggregate_sum(nil), do: 0
defp normalize_aggregate_sum(%Decimal{} = value), do: Decimal.to_integer(value)
defp normalize_aggregate_sum(value) when is_integer(value), do: value

defp ids(companies) do
Enum.map(companies, fn c -> c.id end)
end
Expand Down
Loading