mirror of
https://github.com/ClaperCo/Claper.git
synced 2025-12-16 03:47:56 +01:00
636 lines
16 KiB
Elixir
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
|