Files
Claper/lib/claper/admin.ex
2025-11-20 10:44:06 +01:00

636 lines
16 KiB
Elixir

defmodule Claper.Admin do
@moduledoc """
The Admin context.
Provides functions for admin dashboard statistics and paginated lists of resources.
"""
import Ecto.Query, warn: false
alias Claper.Repo
alias Claper.Accounts.User
alias Claper.Events.Event
alias Claper.Accounts.Oidc.Provider
@doc """
Gets dashboard statistics.
Returns a map with counts of users, events, and upcoming events.
## Examples
iex> get_dashboard_stats()
%{users_count: 10, events_count: 20, upcoming_events: 5}
"""
def get_dashboard_stats do
users_count =
User
|> where([u], is_nil(u.deleted_at))
|> Repo.aggregate(:count, :id)
events_count =
Event
|> Repo.aggregate(:count, :id)
_now = NaiveDateTime.utc_now()
upcoming_events =
Event
|> where([e], is_nil(e.expired_at))
|> Repo.aggregate(:count, :id)
%{
users_count: users_count,
events_count: events_count,
upcoming_events: upcoming_events
}
end
@doc """
Gets users over time for analytics charts.
Returns user registration data grouped by time period.
## Parameters
- period: :day, :week, :month (default: :day)
- days_back: number of days to look back (default: 30)
## Examples
iex> get_users_over_time(:day, 7)
%{
labels: ["2025-01-10", "2025-01-11", ...],
values: [2, 5, 1, 3, ...]
}
"""
def get_users_over_time(period \\ :day, days_back \\ 30) do
end_date = NaiveDateTime.utc_now()
start_date = NaiveDateTime.add(end_date, -(days_back * 24 * 60 * 60), :second)
# Generate all dates in the range
date_range = generate_date_range(start_date, end_date, period)
# Get actual user counts per period using raw SQL to avoid parameter conflicts
period_sql_value = period_sql(period)
sql = """
SELECT DATE_TRUNC($1, inserted_at) as period, COUNT(id) as count
FROM users
WHERE deleted_at IS NULL
AND inserted_at >= $2
AND inserted_at <= $3
GROUP BY DATE_TRUNC($1, inserted_at)
ORDER BY period
"""
result = Repo.query!(sql, [period_sql_value, start_date, end_date])
user_counts =
result.rows
|> Enum.map(fn [period, count] ->
normalized_period = NaiveDateTime.truncate(period, :second)
{normalized_period, count}
end)
|> Enum.into(%{})
# Format data for charts
labels = Enum.map(date_range, &format_date_label(&1, period))
values =
Enum.map(date_range, fn date ->
Map.get(user_counts, truncate_date(date, period), 0)
end)
%{
labels: labels,
values: values
}
end
@doc """
Gets events over time for analytics charts.
Returns event creation data grouped by time period.
## Parameters
- period: :day, :week, :month (default: :day)
- days_back: number of days to look back (default: 30)
## Examples
iex> get_events_over_time(:day, 7)
%{
labels: ["2025-01-10", "2025-01-11", ...],
values: [1, 3, 0, 2, ...]
}
"""
def get_events_over_time(period \\ :day, days_back \\ 30) do
end_date = NaiveDateTime.utc_now()
start_date = NaiveDateTime.add(end_date, -(days_back * 24 * 60 * 60), :second)
# Generate all dates in the range
date_range = generate_date_range(start_date, end_date, period)
# Get actual event counts per period using raw SQL to avoid parameter conflicts
period_sql_value = period_sql(period)
sql = """
SELECT DATE_TRUNC($1, inserted_at) as period, COUNT(id) as count
FROM events
WHERE inserted_at >= $2
AND inserted_at <= $3
GROUP BY DATE_TRUNC($1, inserted_at)
ORDER BY period
"""
result = Repo.query!(sql, [period_sql_value, start_date, end_date])
event_counts =
result.rows
|> Enum.map(fn [period, count] ->
# Normalize the timestamp by removing microseconds
normalized_period = NaiveDateTime.truncate(period, :second)
{normalized_period, count}
end)
|> Enum.into(%{})
# Format data for charts
labels = Enum.map(date_range, &format_date_label(&1, period))
values =
Enum.map(date_range, fn date ->
Map.get(event_counts, truncate_date(date, period), 0)
end)
%{
labels: labels,
values: values
}
end
@doc """
Gets growth metrics for dashboard statistics.
Returns percentage growth for users and events compared to previous period.
"""
def get_growth_metrics do
now = NaiveDateTime.utc_now()
thirty_days_ago = NaiveDateTime.add(now, -(30 * 24 * 60 * 60), :second)
sixty_days_ago = NaiveDateTime.add(now, -(60 * 24 * 60 * 60), :second)
# Current period (last 30 days)
current_users =
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^thirty_days_ago and u.inserted_at <= ^now)
|> Repo.aggregate(:count, :id)
current_events =
Event
|> where([e], e.inserted_at >= ^thirty_days_ago and e.inserted_at <= ^now)
|> Repo.aggregate(:count, :id)
# Previous period (30-60 days ago)
previous_users =
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^sixty_days_ago and u.inserted_at < ^thirty_days_ago)
|> Repo.aggregate(:count, :id)
previous_events =
Event
|> where([e], e.inserted_at >= ^sixty_days_ago and e.inserted_at < ^thirty_days_ago)
|> Repo.aggregate(:count, :id)
%{
users_growth: calculate_growth_percentage(current_users, previous_users),
events_growth: calculate_growth_percentage(current_events, previous_events)
}
end
@doc """
Gets recent activity stats for dashboard.
Returns counts of recent activities.
"""
def get_activity_stats do
now = NaiveDateTime.utc_now()
twenty_four_hours_ago = NaiveDateTime.add(now, -(24 * 60 * 60), :second)
seven_days_ago = NaiveDateTime.add(now, -(7 * 24 * 60 * 60), :second)
%{
users_today:
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^twenty_four_hours_ago)
|> Repo.aggregate(:count, :id),
events_today:
Event
|> where([e], e.inserted_at >= ^twenty_four_hours_ago)
|> Repo.aggregate(:count, :id),
users_this_week:
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^seven_days_ago)
|> Repo.aggregate(:count, :id),
events_this_week:
Event
|> where([e], e.inserted_at >= ^seven_days_ago)
|> Repo.aggregate(:count, :id)
}
end
# Private helper functions
defp generate_date_range(start_date, end_date, period) do
start_date
|> NaiveDateTime.to_date()
|> Date.range(NaiveDateTime.to_date(end_date))
|> Enum.to_list()
|> case do
dates when period == :day ->
dates
dates when period == :week ->
dates |> Enum.chunk_every(7) |> Enum.map(&List.first/1)
dates when period == :month ->
dates |> Enum.group_by(&Date.beginning_of_month/1) |> Map.keys()
end
end
defp period_sql(period) do
case period do
:day -> "day"
:week -> "week"
:month -> "month"
end
end
defp format_date_label(date, period) do
case period do
:day -> Date.to_string(date)
:week -> "Week of #{Date.to_string(date)}"
:month -> "#{Date.to_string(date) |> String.slice(0..6)}"
end
end
defp truncate_date(date, period) do
naive_date = NaiveDateTime.new!(date, ~T[00:00:00])
case period do
:day ->
NaiveDateTime.truncate(naive_date, :second)
:week ->
days_to_subtract = Date.day_of_week(date) - 1
date
|> Date.add(-days_to_subtract)
|> NaiveDateTime.new!(~T[00:00:00])
|> NaiveDateTime.truncate(:second)
:month ->
date
|> Date.beginning_of_month()
|> NaiveDateTime.new!(~T[00:00:00])
|> NaiveDateTime.truncate(:second)
end
end
defp calculate_growth_percentage(current, previous) do
cond do
previous == 0 and current > 0 ->
100.0
previous == 0 and current == 0 ->
0.0
previous > 0 ->
:erlang.float_to_binary(((current - previous) / previous * 100) |> Float.round(1),
decimals: 1
)
true ->
0.0
end
end
@doc """
Returns a paginated list of users.
## Options
* `:page` - The page number (default: 1)
* `:per_page` - The number of users per page (default: 20)
* `:search` - Search term for filtering users by email
* `:role` - Filter users by role name
## Examples
iex> list_users_paginated(%{page: 1, per_page: 10})
%{entries: [%User{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
"""
def list_users_paginated(params \\ %{}) do
page = Map.get(params, "page", 1)
per_page = Map.get(params, "per_page", 20)
search = Map.get(params, "search", "")
role = Map.get(params, "role", "")
query =
User
|> where([u], is_nil(u.deleted_at))
|> preload(:role)
query =
if search != "" do
query |> where([u], ilike(u.email, ^"%#{search}%"))
else
query
end
query =
if role != "" do
query |> join(:inner, [u], r in assoc(u, :role), on: r.name == ^role)
else
query
end
query = query |> order_by([u], desc: u.inserted_at)
Repo.paginate(query, page: page, page_size: per_page)
end
@doc """
Returns a paginated list of events.
## Options
* `:page` - The page number (default: 1)
* `:per_page` - The number of events per page (default: 20)
* `:search` - Search term for filtering events by name
* `:status` - Filter events by status (upcoming, past)
* `:start_date` - Filter events by start date
* `:end_date` - Filter events by end date
* `:creator_id` - Filter events by creator ID
## Examples
iex> list_events_paginated(%{page: 1, per_page: 10})
%{entries: [%Event{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
"""
def list_events_paginated(params \\ %{}) do
page = Map.get(params, "page", 1)
per_page = Map.get(params, "per_page", 20)
search = Map.get(params, "search", "")
status = Map.get(params, "status", "")
start_date = Map.get(params, "start_date", nil)
end_date = Map.get(params, "end_date", nil)
creator_id = Map.get(params, "creator_id", nil)
query =
Event
|> preload(:user)
query =
if search != "" do
query |> where([e], ilike(e.name, ^"%#{search}%"))
else
query
end
query =
case status do
"upcoming" ->
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at > ^now)
"past" ->
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at <= ^now)
_ ->
query
end
query =
if start_date do
query |> where([e], e.started_at >= ^start_date)
else
query
end
query =
if end_date do
query |> where([e], e.started_at <= ^end_date)
else
query
end
query =
if creator_id do
query |> where([e], e.user_id == ^creator_id)
else
query
end
query = query |> order_by([e], desc: e.started_at)
Repo.paginate(query, page: page, page_size: per_page)
end
@doc """
Returns a paginated list of OIDC providers.
## Options
* `:page` - The page number (default: 1)
* `:per_page` - The number of providers per page (default: 20)
* `:search` - Search term for filtering providers by name
## Examples
iex> list_oidc_providers_paginated(%{page: 1, per_page: 10})
%{entries: [%Provider{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
"""
def list_oidc_providers_paginated(params \\ %{}) do
page = Map.get(params, "page", 1)
per_page = Map.get(params, "per_page", 20)
search = Map.get(params, "search", "")
query = Provider
query =
if search != "" do
query |> where([p], ilike(p.name, ^"%#{search}%"))
else
query
end
query = query |> order_by([p], p.name)
Repo.paginate(query, page: page, page_size: per_page)
end
@doc """
Returns a complete list of OIDC providers for export purposes.
Unlike the paginated version, this returns all providers matching the search criteria.
## Options
* `:search` - Search term for filtering providers by name
## Examples
iex> list_all_oidc_providers(%{search: "Google"})
[%Provider{}, ...]
"""
def list_all_oidc_providers(params \\ %{}) do
search = Map.get(params, "search", "")
query = Provider
query =
if search != "" do
query |> where([p], ilike(p.name, ^"%#{search}%"))
else
query
end
query = query |> order_by([p], p.name)
Repo.all(query)
end
@doc """
Returns a complete list of events for export purposes.
Unlike the paginated version, this returns all events matching the search criteria.
## Options
* `:search` - Search term for filtering events by name
* `:status` - Filter events by status (upcoming, past)
* `:start_date` - Filter events by start date
* `:end_date` - Filter events by end date
* `:creator_id` - Filter events by creator ID
## Examples
iex> list_all_events(%{search: "Conference"})
[%Event{}, ...]
"""
def list_all_events(params \\ %{}) do
Event
|> join(:left, [e], u in assoc(e, :user))
|> preload([e, u], user: u)
|> apply_event_search_filter(Map.get(params, "search", ""))
|> apply_event_status_filter(Map.get(params, "status", ""))
|> apply_event_start_date_filter(Map.get(params, "start_date", nil))
|> apply_event_end_date_filter(Map.get(params, "end_date", nil))
|> apply_event_creator_filter(Map.get(params, "creator_id", nil))
|> order_by([e], desc: e.started_at)
|> Repo.all()
|> Enum.map(fn event -> Map.put(event, :user_email, event.user.email) end)
end
defp apply_event_search_filter(query, ""), do: query
defp apply_event_search_filter(query, search) do
search_term = "%#{search}%"
query
|> where(
[e, u],
ilike(e.name, ^search_term) or ilike(e.code, ^search_term) or
ilike(u.email, ^search_term)
)
end
defp apply_event_status_filter(query, "upcoming") do
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at > ^now)
end
defp apply_event_status_filter(query, "past") do
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at <= ^now)
end
defp apply_event_status_filter(query, _), do: query
defp apply_event_start_date_filter(query, nil), do: query
defp apply_event_start_date_filter(query, start_date) do
query |> where([e], e.started_at >= ^start_date)
end
defp apply_event_end_date_filter(query, nil), do: query
defp apply_event_end_date_filter(query, end_date) do
query |> where([e], e.started_at <= ^end_date)
end
defp apply_event_creator_filter(query, nil), do: query
defp apply_event_creator_filter(query, creator_id) do
query |> where([e], e.user_id == ^creator_id)
end
@doc """
Returns a complete list of users for export purposes.
Unlike the paginated version, this returns all users matching the search criteria.
## Options
* `:search` - Search term for filtering users by email or name
* `:role` - Filter users by role ID
## Examples
iex> list_all_users(%{search: "admin"})
[%User{}, ...]
"""
def list_all_users(params \\ %{}) do
search = Map.get(params, "search", "")
role = Map.get(params, "role", "")
query =
User
|> where([u], is_nil(u.deleted_at))
|> preload(:role)
query =
if search != "" do
query |> where([u], ilike(u.email, ^"%#{search}%") or ilike(u.name, ^"%#{search}%"))
else
query
end
query =
if role != "" do
query |> where([u], u.role_id == ^role)
else
query
end
query = query |> order_by([u], u.email)
# Add a virtual field for role_name to make it accessible in CSV export
Repo.all(query)
|> Enum.map(fn user ->
role_name = if user.role, do: user.role.name, else: "none"
user
|> Map.put(:role_name, role_name)
end)
end
end