Add date-range filtering to analytics dashboard API.

Support all-time, year, year+month, and custom from/to query params with filtered metrics and time-series charts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-15 02:15:15 -07:00
parent 8bba318372
commit 024a69b74b
8 changed files with 960 additions and 257 deletions

View File

@ -1,6 +1,12 @@
-- =====================
-- Analytics
-- Analytics (date-filtered)
-- =====================
-- Shared optional params (nullable = all-time):
-- range_start, range_end (exclusive upper bound)
-- Required chart params:
-- series_start, series_end (inclusive dates)
-- Relative window anchor:
-- ref_date (inclusive date used for new_today/week/month)
-- =====================
-- User Analytics
@ -9,49 +15,67 @@
-- name: AnalyticsUsersSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE)::bigint AS new_today,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '7 days')::bigint AS new_this_week,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days')::bigint AS new_this_month
FROM users;
COUNT(*) FILTER (WHERE u.created_at::date = sqlc.arg('ref_date')::date)::bigint AS new_today,
COUNT(*) FILTER (
WHERE u.created_at::date >= sqlc.arg('ref_date')::date - INTERVAL '6 days'
AND u.created_at::date <= sqlc.arg('ref_date')::date
)::bigint AS new_this_week,
COUNT(*) FILTER (
WHERE u.created_at::date >= sqlc.arg('ref_date')::date - INTERVAL '29 days'
AND u.created_at::date <= sqlc.arg('ref_date')::date
)::bigint AS new_this_month
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz);
-- name: AnalyticsUsersByRole :many
SELECT
COALESCE(role, 'unknown') AS role,
COALESCE(u.role, 'unknown') AS role,
COUNT(*)::bigint AS count
FROM users
GROUP BY role
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.role
ORDER BY count DESC;
-- name: AnalyticsUsersByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(u.status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM users
GROUP BY status
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.status
ORDER BY count DESC;
-- name: AnalyticsUsersByAgeGroup :many
SELECT
COALESCE(age_group, 'unknown') AS age_group,
COALESCE(u.age_group, 'unknown') AS age_group,
COUNT(*)::bigint AS count
FROM users
GROUP BY age_group
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.age_group
ORDER BY count DESC;
-- name: AnalyticsUsersByKnowledgeLevel :many
SELECT
COALESCE(knowledge_level, 'unknown') AS knowledge_level,
COALESCE(u.knowledge_level, 'unknown') AS knowledge_level,
COUNT(*)::bigint AS count
FROM users
GROUP BY knowledge_level
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.knowledge_level
ORDER BY count DESC;
-- name: AnalyticsUsersByRegion :many
SELECT
COALESCE(region, 'unknown') AS region,
COALESCE(u.region, 'unknown') AS region,
COUNT(*)::bigint AS count
FROM users
GROUP BY region
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY u.region
ORDER BY count DESC;
-- name: AnalyticsUserRegistrationsLast30Days :many
@ -59,11 +83,13 @@ SELECT
d.date,
COUNT(u.id)::bigint AS count
FROM generate_series(
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
sqlc.arg('series_start')::date,
sqlc.arg('series_end')::date,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN users u ON u.created_at::date = d.date
AND (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY d.date
ORDER BY d.date;
@ -74,18 +100,28 @@ ORDER BY d.date;
-- name: AnalyticsSubscriptionsSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status = 'ACTIVE')::bigint AS active,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE)::bigint AS new_today,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '7 days')::bigint AS new_this_week,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days')::bigint AS new_this_month
FROM user_subscriptions;
COUNT(*) FILTER (WHERE us.status = 'ACTIVE')::bigint AS active,
COUNT(*) FILTER (WHERE us.created_at::date = sqlc.arg('ref_date')::date)::bigint AS new_today,
COUNT(*) FILTER (
WHERE us.created_at::date >= sqlc.arg('ref_date')::date - INTERVAL '6 days'
AND us.created_at::date <= sqlc.arg('ref_date')::date
)::bigint AS new_this_week,
COUNT(*) FILTER (
WHERE us.created_at::date >= sqlc.arg('ref_date')::date - INTERVAL '29 days'
AND us.created_at::date <= sqlc.arg('ref_date')::date
)::bigint AS new_this_month
FROM user_subscriptions us
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR us.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR us.created_at < sqlc.narg('range_end')::timestamptz);
-- name: AnalyticsSubscriptionsByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(us.status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM user_subscriptions
GROUP BY status
FROM user_subscriptions us
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR us.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR us.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY us.status
ORDER BY count DESC;
-- name: AnalyticsRevenueByPlan :many
@ -97,6 +133,8 @@ SELECT
FROM payments p
JOIN subscription_plans sp ON sp.id = p.plan_id
WHERE p.status = 'SUCCESS'
AND (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz)
GROUP BY sp.name, sp.currency
ORDER BY total_revenue DESC;
@ -105,11 +143,13 @@ SELECT
d.date,
COUNT(us.id)::bigint AS count
FROM generate_series(
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
sqlc.arg('series_start')::date,
sqlc.arg('series_end')::date,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN user_subscriptions us ON us.created_at::date = d.date
AND (sqlc.narg('range_start')::timestamptz IS NULL OR us.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR us.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY d.date
ORDER BY d.date;
@ -119,29 +159,35 @@ ORDER BY d.date;
-- name: AnalyticsPaymentsSummary :one
SELECT
COALESCE(SUM(amount) FILTER (WHERE status = 'SUCCESS'), 0)::float8 AS total_revenue,
COALESCE(AVG(amount) FILTER (WHERE status = 'SUCCESS'), 0)::float8 AS avg_value,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'SUCCESS'), 0)::float8 AS total_revenue,
COALESCE(AVG(p.amount) FILTER (WHERE p.status = 'SUCCESS'), 0)::float8 AS avg_value,
COUNT(*)::bigint AS total_payments,
COUNT(*) FILTER (WHERE status = 'SUCCESS')::bigint AS successful_payments
FROM payments;
COUNT(*) FILTER (WHERE p.status = 'SUCCESS')::bigint AS successful_payments
FROM payments p
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz);
-- name: AnalyticsPaymentsByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(p.status, 'unknown') AS status,
COUNT(*)::bigint AS count,
COALESCE(SUM(amount), 0)::float8 AS total_amount
FROM payments
GROUP BY status
COALESCE(SUM(p.amount), 0)::float8 AS total_amount
FROM payments p
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz)
GROUP BY p.status
ORDER BY count DESC;
-- name: AnalyticsPaymentsByMethod :many
SELECT
COALESCE(payment_method, 'unknown') AS payment_method,
COALESCE(p.payment_method, 'unknown') AS payment_method,
COUNT(*)::bigint AS count,
COALESCE(SUM(amount), 0)::float8 AS total_amount
FROM payments
WHERE status = 'SUCCESS'
GROUP BY payment_method
COALESCE(SUM(p.amount), 0)::float8 AS total_amount
FROM payments p
WHERE p.status = 'SUCCESS'
AND (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz)
GROUP BY p.payment_method
ORDER BY count DESC;
-- name: AnalyticsRevenueLast30Days :many
@ -149,11 +195,14 @@ SELECT
d.date,
COALESCE(SUM(p.amount), 0)::float8 AS total_revenue
FROM generate_series(
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
sqlc.arg('series_start')::date,
sqlc.arg('series_end')::date,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN payments p ON p.paid_at::date = d.date AND p.status = 'SUCCESS'
LEFT JOIN payments p ON COALESCE(p.paid_at, p.created_at)::date = d.date
AND p.status = 'SUCCESS'
AND (sqlc.narg('range_start')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < sqlc.narg('range_end')::timestamptz)
GROUP BY d.date
ORDER BY d.date;
@ -174,23 +223,37 @@ SELECT
-- name: AnalyticsQuestionsCounts :one
SELECT
(SELECT COUNT(*)::bigint FROM questions) AS total_questions,
(SELECT COUNT(*)::bigint FROM question_sets) AS total_question_sets;
(
SELECT COUNT(*)::bigint
FROM questions q
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR q.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR q.created_at < sqlc.narg('range_end')::timestamptz)
) AS total_questions,
(
SELECT COUNT(*)::bigint
FROM question_sets qs
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR qs.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR qs.created_at < sqlc.narg('range_end')::timestamptz)
) AS total_question_sets;
-- name: AnalyticsQuestionsByType :many
SELECT
COALESCE(question_type, 'unknown') AS question_type,
COALESCE(q.question_type, 'unknown') AS question_type,
COUNT(*)::bigint AS count
FROM questions
GROUP BY question_type
FROM questions q
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR q.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR q.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY q.question_type
ORDER BY count DESC;
-- name: AnalyticsQuestionSetsByType :many
SELECT
COALESCE(set_type, 'unknown') AS set_type,
COALESCE(qs.set_type, 'unknown') AS set_type,
COUNT(*)::bigint AS count
FROM question_sets
GROUP BY set_type
FROM question_sets qs
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR qs.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR qs.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY qs.set_type
ORDER BY count DESC;
-- =====================
@ -200,24 +263,30 @@ ORDER BY count DESC;
-- name: AnalyticsNotificationsSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE is_read = TRUE)::bigint AS read,
COUNT(*) FILTER (WHERE is_read = FALSE)::bigint AS unread
FROM notifications;
COUNT(*) FILTER (WHERE n.is_read = TRUE)::bigint AS read,
COUNT(*) FILTER (WHERE n.is_read = FALSE)::bigint AS unread
FROM notifications n
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR n.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR n.created_at < sqlc.narg('range_end')::timestamptz);
-- name: AnalyticsNotificationsByChannel :many
SELECT
COALESCE(channel, 'unknown') AS channel,
COALESCE(n.channel, 'unknown') AS channel,
COUNT(*)::bigint AS count
FROM notifications
GROUP BY channel
FROM notifications n
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR n.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR n.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY n.channel
ORDER BY count DESC;
-- name: AnalyticsNotificationsByType :many
SELECT
COALESCE(type, 'unknown') AS type,
COALESCE(n.type, 'unknown') AS type,
COUNT(*)::bigint AS count
FROM notifications
GROUP BY type
FROM notifications n
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR n.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR n.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY n.type
ORDER BY count DESC;
-- =====================
@ -227,27 +296,33 @@ ORDER BY count DESC;
-- name: AnalyticsIssuesSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status = 'resolved')::bigint AS resolved,
COUNT(*) FILTER (WHERE ri.status = 'resolved')::bigint AS resolved,
CASE
WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE status = 'resolved')::float8 / COUNT(*)::float8)
WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE ri.status = 'resolved')::float8 / COUNT(*)::float8)
ELSE 0::float8
END AS resolution_rate
FROM reported_issues;
FROM reported_issues ri
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR ri.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR ri.created_at < sqlc.narg('range_end')::timestamptz);
-- name: AnalyticsIssuesByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(ri.status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM reported_issues
GROUP BY status
FROM reported_issues ri
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR ri.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR ri.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY ri.status
ORDER BY count DESC;
-- name: AnalyticsIssuesByType :many
SELECT
COALESCE(issue_type, 'unknown') AS issue_type,
COALESCE(ri.issue_type, 'unknown') AS issue_type,
COUNT(*)::bigint AS count
FROM reported_issues
GROUP BY issue_type
FROM reported_issues ri
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR ri.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR ri.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY ri.issue_type
ORDER BY count DESC;
-- =====================
@ -257,20 +332,26 @@ ORDER BY count DESC;
-- name: AnalyticsTeamSummary :one
SELECT
COUNT(*)::bigint AS total_members
FROM team_members;
FROM team_members tm
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR tm.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR tm.created_at < sqlc.narg('range_end')::timestamptz);
-- name: AnalyticsTeamByRole :many
SELECT
COALESCE(team_role, 'unknown') AS team_role,
COALESCE(tm.team_role, 'unknown') AS team_role,
COUNT(*)::bigint AS count
FROM team_members
GROUP BY team_role
FROM team_members tm
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR tm.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR tm.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY tm.team_role
ORDER BY count DESC;
-- name: AnalyticsTeamByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(tm.status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM team_members
GROUP BY status
FROM team_members tm
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR tm.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR tm.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY tm.status
ORDER BY count DESC;

View File

@ -7,6 +7,8 @@ package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one
@ -42,20 +44,27 @@ func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCou
const AnalyticsIssuesByStatus = `-- name: AnalyticsIssuesByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(ri.status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM reported_issues
GROUP BY status
FROM reported_issues ri
WHERE ($1::timestamptz IS NULL OR ri.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR ri.created_at < $2::timestamptz)
GROUP BY ri.status
ORDER BY count DESC
`
type AnalyticsIssuesByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsIssuesByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsIssuesByStatus(ctx context.Context) ([]AnalyticsIssuesByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsIssuesByStatus)
func (q *Queries) AnalyticsIssuesByStatus(ctx context.Context, arg AnalyticsIssuesByStatusParams) ([]AnalyticsIssuesByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsIssuesByStatus, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -76,20 +85,27 @@ func (q *Queries) AnalyticsIssuesByStatus(ctx context.Context) ([]AnalyticsIssue
const AnalyticsIssuesByType = `-- name: AnalyticsIssuesByType :many
SELECT
COALESCE(issue_type, 'unknown') AS issue_type,
COALESCE(ri.issue_type, 'unknown') AS issue_type,
COUNT(*)::bigint AS count
FROM reported_issues
GROUP BY issue_type
FROM reported_issues ri
WHERE ($1::timestamptz IS NULL OR ri.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR ri.created_at < $2::timestamptz)
GROUP BY ri.issue_type
ORDER BY count DESC
`
type AnalyticsIssuesByTypeParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsIssuesByTypeRow struct {
IssueType string `json:"issue_type"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsIssuesByType(ctx context.Context) ([]AnalyticsIssuesByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsIssuesByType)
func (q *Queries) AnalyticsIssuesByType(ctx context.Context, arg AnalyticsIssuesByTypeParams) ([]AnalyticsIssuesByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsIssuesByType, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -112,14 +128,21 @@ const AnalyticsIssuesSummary = `-- name: AnalyticsIssuesSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status = 'resolved')::bigint AS resolved,
COUNT(*) FILTER (WHERE ri.status = 'resolved')::bigint AS resolved,
CASE
WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE status = 'resolved')::float8 / COUNT(*)::float8)
WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE ri.status = 'resolved')::float8 / COUNT(*)::float8)
ELSE 0::float8
END AS resolution_rate
FROM reported_issues
FROM reported_issues ri
WHERE ($1::timestamptz IS NULL OR ri.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR ri.created_at < $2::timestamptz)
`
type AnalyticsIssuesSummaryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsIssuesSummaryRow struct {
Total int64 `json:"total"`
Resolved int64 `json:"resolved"`
@ -129,8 +152,8 @@ type AnalyticsIssuesSummaryRow struct {
// =====================
// Issue Analytics
// =====================
func (q *Queries) AnalyticsIssuesSummary(ctx context.Context) (AnalyticsIssuesSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsIssuesSummary)
func (q *Queries) AnalyticsIssuesSummary(ctx context.Context, arg AnalyticsIssuesSummaryParams) (AnalyticsIssuesSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsIssuesSummary, arg.RangeStart, arg.RangeEnd)
var i AnalyticsIssuesSummaryRow
err := row.Scan(&i.Total, &i.Resolved, &i.ResolutionRate)
return i, err
@ -141,22 +164,36 @@ SELECT
d.date,
COUNT(us.id)::bigint AS count
FROM generate_series(
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
$1::date,
$2::date,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN user_subscriptions us ON us.created_at::date = d.date
AND ($3::timestamptz IS NULL OR us.created_at >= $3::timestamptz)
AND ($4::timestamptz IS NULL OR us.created_at < $4::timestamptz)
GROUP BY d.date
ORDER BY d.date
`
type AnalyticsNewSubscriptionsLast30DaysParams struct {
SeriesStart pgtype.Date `json:"series_start"`
SeriesEnd pgtype.Date `json:"series_end"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsNewSubscriptionsLast30DaysRow struct {
Date interface{} `json:"date"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsNewSubscriptionsLast30Days(ctx context.Context) ([]AnalyticsNewSubscriptionsLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNewSubscriptionsLast30Days)
func (q *Queries) AnalyticsNewSubscriptionsLast30Days(ctx context.Context, arg AnalyticsNewSubscriptionsLast30DaysParams) ([]AnalyticsNewSubscriptionsLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNewSubscriptionsLast30Days,
arg.SeriesStart,
arg.SeriesEnd,
arg.RangeStart,
arg.RangeEnd,
)
if err != nil {
return nil, err
}
@ -177,20 +214,27 @@ func (q *Queries) AnalyticsNewSubscriptionsLast30Days(ctx context.Context) ([]An
const AnalyticsNotificationsByChannel = `-- name: AnalyticsNotificationsByChannel :many
SELECT
COALESCE(channel, 'unknown') AS channel,
COALESCE(n.channel, 'unknown') AS channel,
COUNT(*)::bigint AS count
FROM notifications
GROUP BY channel
FROM notifications n
WHERE ($1::timestamptz IS NULL OR n.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR n.created_at < $2::timestamptz)
GROUP BY n.channel
ORDER BY count DESC
`
type AnalyticsNotificationsByChannelParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsNotificationsByChannelRow struct {
Channel string `json:"channel"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsNotificationsByChannel(ctx context.Context) ([]AnalyticsNotificationsByChannelRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNotificationsByChannel)
func (q *Queries) AnalyticsNotificationsByChannel(ctx context.Context, arg AnalyticsNotificationsByChannelParams) ([]AnalyticsNotificationsByChannelRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNotificationsByChannel, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -211,20 +255,27 @@ func (q *Queries) AnalyticsNotificationsByChannel(ctx context.Context) ([]Analyt
const AnalyticsNotificationsByType = `-- name: AnalyticsNotificationsByType :many
SELECT
COALESCE(type, 'unknown') AS type,
COALESCE(n.type, 'unknown') AS type,
COUNT(*)::bigint AS count
FROM notifications
GROUP BY type
FROM notifications n
WHERE ($1::timestamptz IS NULL OR n.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR n.created_at < $2::timestamptz)
GROUP BY n.type
ORDER BY count DESC
`
type AnalyticsNotificationsByTypeParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsNotificationsByTypeRow struct {
Type string `json:"type"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsNotificationsByType(ctx context.Context) ([]AnalyticsNotificationsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNotificationsByType)
func (q *Queries) AnalyticsNotificationsByType(ctx context.Context, arg AnalyticsNotificationsByTypeParams) ([]AnalyticsNotificationsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsNotificationsByType, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -247,11 +298,18 @@ const AnalyticsNotificationsSummary = `-- name: AnalyticsNotificationsSummary :o
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE is_read = TRUE)::bigint AS read,
COUNT(*) FILTER (WHERE is_read = FALSE)::bigint AS unread
FROM notifications
COUNT(*) FILTER (WHERE n.is_read = TRUE)::bigint AS read,
COUNT(*) FILTER (WHERE n.is_read = FALSE)::bigint AS unread
FROM notifications n
WHERE ($1::timestamptz IS NULL OR n.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR n.created_at < $2::timestamptz)
`
type AnalyticsNotificationsSummaryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsNotificationsSummaryRow struct {
Total int64 `json:"total"`
Read int64 `json:"read"`
@ -261,8 +319,8 @@ type AnalyticsNotificationsSummaryRow struct {
// =====================
// Notification Analytics
// =====================
func (q *Queries) AnalyticsNotificationsSummary(ctx context.Context) (AnalyticsNotificationsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsNotificationsSummary)
func (q *Queries) AnalyticsNotificationsSummary(ctx context.Context, arg AnalyticsNotificationsSummaryParams) (AnalyticsNotificationsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsNotificationsSummary, arg.RangeStart, arg.RangeEnd)
var i AnalyticsNotificationsSummaryRow
err := row.Scan(&i.Total, &i.Read, &i.Unread)
return i, err
@ -270,23 +328,30 @@ func (q *Queries) AnalyticsNotificationsSummary(ctx context.Context) (AnalyticsN
const AnalyticsPaymentsByMethod = `-- name: AnalyticsPaymentsByMethod :many
SELECT
COALESCE(payment_method, 'unknown') AS payment_method,
COALESCE(p.payment_method, 'unknown') AS payment_method,
COUNT(*)::bigint AS count,
COALESCE(SUM(amount), 0)::float8 AS total_amount
FROM payments
WHERE status = 'SUCCESS'
GROUP BY payment_method
COALESCE(SUM(p.amount), 0)::float8 AS total_amount
FROM payments p
WHERE p.status = 'SUCCESS'
AND ($1::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $2::timestamptz)
GROUP BY p.payment_method
ORDER BY count DESC
`
type AnalyticsPaymentsByMethodParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsPaymentsByMethodRow struct {
PaymentMethod string `json:"payment_method"`
Count int64 `json:"count"`
TotalAmount float64 `json:"total_amount"`
}
func (q *Queries) AnalyticsPaymentsByMethod(ctx context.Context) ([]AnalyticsPaymentsByMethodRow, error) {
rows, err := q.db.Query(ctx, AnalyticsPaymentsByMethod)
func (q *Queries) AnalyticsPaymentsByMethod(ctx context.Context, arg AnalyticsPaymentsByMethodParams) ([]AnalyticsPaymentsByMethodRow, error) {
rows, err := q.db.Query(ctx, AnalyticsPaymentsByMethod, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -307,22 +372,29 @@ func (q *Queries) AnalyticsPaymentsByMethod(ctx context.Context) ([]AnalyticsPay
const AnalyticsPaymentsByStatus = `-- name: AnalyticsPaymentsByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(p.status, 'unknown') AS status,
COUNT(*)::bigint AS count,
COALESCE(SUM(amount), 0)::float8 AS total_amount
FROM payments
GROUP BY status
COALESCE(SUM(p.amount), 0)::float8 AS total_amount
FROM payments p
WHERE ($1::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $2::timestamptz)
GROUP BY p.status
ORDER BY count DESC
`
type AnalyticsPaymentsByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsPaymentsByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
TotalAmount float64 `json:"total_amount"`
}
func (q *Queries) AnalyticsPaymentsByStatus(ctx context.Context) ([]AnalyticsPaymentsByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsPaymentsByStatus)
func (q *Queries) AnalyticsPaymentsByStatus(ctx context.Context, arg AnalyticsPaymentsByStatusParams) ([]AnalyticsPaymentsByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsPaymentsByStatus, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -344,13 +416,20 @@ func (q *Queries) AnalyticsPaymentsByStatus(ctx context.Context) ([]AnalyticsPay
const AnalyticsPaymentsSummary = `-- name: AnalyticsPaymentsSummary :one
SELECT
COALESCE(SUM(amount) FILTER (WHERE status = 'SUCCESS'), 0)::float8 AS total_revenue,
COALESCE(AVG(amount) FILTER (WHERE status = 'SUCCESS'), 0)::float8 AS avg_value,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'SUCCESS'), 0)::float8 AS total_revenue,
COALESCE(AVG(p.amount) FILTER (WHERE p.status = 'SUCCESS'), 0)::float8 AS avg_value,
COUNT(*)::bigint AS total_payments,
COUNT(*) FILTER (WHERE status = 'SUCCESS')::bigint AS successful_payments
FROM payments
COUNT(*) FILTER (WHERE p.status = 'SUCCESS')::bigint AS successful_payments
FROM payments p
WHERE ($1::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $2::timestamptz)
`
type AnalyticsPaymentsSummaryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsPaymentsSummaryRow struct {
TotalRevenue float64 `json:"total_revenue"`
AvgValue float64 `json:"avg_value"`
@ -361,8 +440,8 @@ type AnalyticsPaymentsSummaryRow struct {
// =====================
// Payment Analytics
// =====================
func (q *Queries) AnalyticsPaymentsSummary(ctx context.Context) (AnalyticsPaymentsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsPaymentsSummary)
func (q *Queries) AnalyticsPaymentsSummary(ctx context.Context, arg AnalyticsPaymentsSummaryParams) (AnalyticsPaymentsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsPaymentsSummary, arg.RangeStart, arg.RangeEnd)
var i AnalyticsPaymentsSummaryRow
err := row.Scan(
&i.TotalRevenue,
@ -375,20 +454,27 @@ func (q *Queries) AnalyticsPaymentsSummary(ctx context.Context) (AnalyticsPaymen
const AnalyticsQuestionSetsByType = `-- name: AnalyticsQuestionSetsByType :many
SELECT
COALESCE(set_type, 'unknown') AS set_type,
COALESCE(qs.set_type, 'unknown') AS set_type,
COUNT(*)::bigint AS count
FROM question_sets
GROUP BY set_type
FROM question_sets qs
WHERE ($1::timestamptz IS NULL OR qs.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR qs.created_at < $2::timestamptz)
GROUP BY qs.set_type
ORDER BY count DESC
`
type AnalyticsQuestionSetsByTypeParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsQuestionSetsByTypeRow struct {
SetType string `json:"set_type"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsQuestionSetsByType(ctx context.Context) ([]AnalyticsQuestionSetsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsQuestionSetsByType)
func (q *Queries) AnalyticsQuestionSetsByType(ctx context.Context, arg AnalyticsQuestionSetsByTypeParams) ([]AnalyticsQuestionSetsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsQuestionSetsByType, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -409,20 +495,27 @@ func (q *Queries) AnalyticsQuestionSetsByType(ctx context.Context) ([]AnalyticsQ
const AnalyticsQuestionsByType = `-- name: AnalyticsQuestionsByType :many
SELECT
COALESCE(question_type, 'unknown') AS question_type,
COALESCE(q.question_type, 'unknown') AS question_type,
COUNT(*)::bigint AS count
FROM questions
GROUP BY question_type
FROM questions q
WHERE ($1::timestamptz IS NULL OR q.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR q.created_at < $2::timestamptz)
GROUP BY q.question_type
ORDER BY count DESC
`
type AnalyticsQuestionsByTypeParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsQuestionsByTypeRow struct {
QuestionType string `json:"question_type"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsQuestionsByType(ctx context.Context) ([]AnalyticsQuestionsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsQuestionsByType)
func (q *Queries) AnalyticsQuestionsByType(ctx context.Context, arg AnalyticsQuestionsByTypeParams) ([]AnalyticsQuestionsByTypeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsQuestionsByType, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -444,10 +537,25 @@ func (q *Queries) AnalyticsQuestionsByType(ctx context.Context) ([]AnalyticsQues
const AnalyticsQuestionsCounts = `-- name: AnalyticsQuestionsCounts :one
SELECT
(SELECT COUNT(*)::bigint FROM questions) AS total_questions,
(SELECT COUNT(*)::bigint FROM question_sets) AS total_question_sets
(
SELECT COUNT(*)::bigint
FROM questions q
WHERE ($1::timestamptz IS NULL OR q.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR q.created_at < $2::timestamptz)
) AS total_questions,
(
SELECT COUNT(*)::bigint
FROM question_sets qs
WHERE ($1::timestamptz IS NULL OR qs.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR qs.created_at < $2::timestamptz)
) AS total_question_sets
`
type AnalyticsQuestionsCountsParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsQuestionsCountsRow struct {
TotalQuestions int64 `json:"total_questions"`
TotalQuestionSets int64 `json:"total_question_sets"`
@ -456,8 +564,8 @@ type AnalyticsQuestionsCountsRow struct {
// =====================
// Content Analytics
// =====================
func (q *Queries) AnalyticsQuestionsCounts(ctx context.Context) (AnalyticsQuestionsCountsRow, error) {
row := q.db.QueryRow(ctx, AnalyticsQuestionsCounts)
func (q *Queries) AnalyticsQuestionsCounts(ctx context.Context, arg AnalyticsQuestionsCountsParams) (AnalyticsQuestionsCountsRow, error) {
row := q.db.QueryRow(ctx, AnalyticsQuestionsCounts, arg.RangeStart, arg.RangeEnd)
var i AnalyticsQuestionsCountsRow
err := row.Scan(&i.TotalQuestions, &i.TotalQuestionSets)
return i, err
@ -472,10 +580,17 @@ SELECT
FROM payments p
JOIN subscription_plans sp ON sp.id = p.plan_id
WHERE p.status = 'SUCCESS'
AND ($1::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $2::timestamptz)
GROUP BY sp.name, sp.currency
ORDER BY total_revenue DESC
`
type AnalyticsRevenueByPlanParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsRevenueByPlanRow struct {
PlanName string `json:"plan_name"`
Currency string `json:"currency"`
@ -483,8 +598,8 @@ type AnalyticsRevenueByPlanRow struct {
TotalRevenue float64 `json:"total_revenue"`
}
func (q *Queries) AnalyticsRevenueByPlan(ctx context.Context) ([]AnalyticsRevenueByPlanRow, error) {
rows, err := q.db.Query(ctx, AnalyticsRevenueByPlan)
func (q *Queries) AnalyticsRevenueByPlan(ctx context.Context, arg AnalyticsRevenueByPlanParams) ([]AnalyticsRevenueByPlanRow, error) {
rows, err := q.db.Query(ctx, AnalyticsRevenueByPlan, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -513,22 +628,37 @@ SELECT
d.date,
COALESCE(SUM(p.amount), 0)::float8 AS total_revenue
FROM generate_series(
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
$1::date,
$2::date,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN payments p ON p.paid_at::date = d.date AND p.status = 'SUCCESS'
LEFT JOIN payments p ON COALESCE(p.paid_at, p.created_at)::date = d.date
AND p.status = 'SUCCESS'
AND ($3::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) >= $3::timestamptz)
AND ($4::timestamptz IS NULL OR COALESCE(p.paid_at, p.created_at) < $4::timestamptz)
GROUP BY d.date
ORDER BY d.date
`
type AnalyticsRevenueLast30DaysParams struct {
SeriesStart pgtype.Date `json:"series_start"`
SeriesEnd pgtype.Date `json:"series_end"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsRevenueLast30DaysRow struct {
Date interface{} `json:"date"`
TotalRevenue float64 `json:"total_revenue"`
}
func (q *Queries) AnalyticsRevenueLast30Days(ctx context.Context) ([]AnalyticsRevenueLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsRevenueLast30Days)
func (q *Queries) AnalyticsRevenueLast30Days(ctx context.Context, arg AnalyticsRevenueLast30DaysParams) ([]AnalyticsRevenueLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsRevenueLast30Days,
arg.SeriesStart,
arg.SeriesEnd,
arg.RangeStart,
arg.RangeEnd,
)
if err != nil {
return nil, err
}
@ -549,20 +679,27 @@ func (q *Queries) AnalyticsRevenueLast30Days(ctx context.Context) ([]AnalyticsRe
const AnalyticsSubscriptionsByStatus = `-- name: AnalyticsSubscriptionsByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(us.status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM user_subscriptions
GROUP BY status
FROM user_subscriptions us
WHERE ($1::timestamptz IS NULL OR us.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR us.created_at < $2::timestamptz)
GROUP BY us.status
ORDER BY count DESC
`
type AnalyticsSubscriptionsByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsSubscriptionsByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsSubscriptionsByStatus(ctx context.Context) ([]AnalyticsSubscriptionsByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsSubscriptionsByStatus)
func (q *Queries) AnalyticsSubscriptionsByStatus(ctx context.Context, arg AnalyticsSubscriptionsByStatusParams) ([]AnalyticsSubscriptionsByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsSubscriptionsByStatus, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -585,13 +722,27 @@ const AnalyticsSubscriptionsSummary = `-- name: AnalyticsSubscriptionsSummary :o
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status = 'ACTIVE')::bigint AS active,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE)::bigint AS new_today,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '7 days')::bigint AS new_this_week,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days')::bigint AS new_this_month
FROM user_subscriptions
COUNT(*) FILTER (WHERE us.status = 'ACTIVE')::bigint AS active,
COUNT(*) FILTER (WHERE us.created_at::date = $1::date)::bigint AS new_today,
COUNT(*) FILTER (
WHERE us.created_at::date >= $1::date - INTERVAL '6 days'
AND us.created_at::date <= $1::date
)::bigint AS new_this_week,
COUNT(*) FILTER (
WHERE us.created_at::date >= $1::date - INTERVAL '29 days'
AND us.created_at::date <= $1::date
)::bigint AS new_this_month
FROM user_subscriptions us
WHERE ($2::timestamptz IS NULL OR us.created_at >= $2::timestamptz)
AND ($3::timestamptz IS NULL OR us.created_at < $3::timestamptz)
`
type AnalyticsSubscriptionsSummaryParams struct {
RefDate pgtype.Date `json:"ref_date"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsSubscriptionsSummaryRow struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
@ -603,8 +754,8 @@ type AnalyticsSubscriptionsSummaryRow struct {
// =====================
// Subscription Analytics
// =====================
func (q *Queries) AnalyticsSubscriptionsSummary(ctx context.Context) (AnalyticsSubscriptionsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsSubscriptionsSummary)
func (q *Queries) AnalyticsSubscriptionsSummary(ctx context.Context, arg AnalyticsSubscriptionsSummaryParams) (AnalyticsSubscriptionsSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsSubscriptionsSummary, arg.RefDate, arg.RangeStart, arg.RangeEnd)
var i AnalyticsSubscriptionsSummaryRow
err := row.Scan(
&i.Total,
@ -618,20 +769,27 @@ func (q *Queries) AnalyticsSubscriptionsSummary(ctx context.Context) (AnalyticsS
const AnalyticsTeamByRole = `-- name: AnalyticsTeamByRole :many
SELECT
COALESCE(team_role, 'unknown') AS team_role,
COALESCE(tm.team_role, 'unknown') AS team_role,
COUNT(*)::bigint AS count
FROM team_members
GROUP BY team_role
FROM team_members tm
WHERE ($1::timestamptz IS NULL OR tm.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR tm.created_at < $2::timestamptz)
GROUP BY tm.team_role
ORDER BY count DESC
`
type AnalyticsTeamByRoleParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsTeamByRoleRow struct {
TeamRole string `json:"team_role"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsTeamByRole(ctx context.Context) ([]AnalyticsTeamByRoleRow, error) {
rows, err := q.db.Query(ctx, AnalyticsTeamByRole)
func (q *Queries) AnalyticsTeamByRole(ctx context.Context, arg AnalyticsTeamByRoleParams) ([]AnalyticsTeamByRoleRow, error) {
rows, err := q.db.Query(ctx, AnalyticsTeamByRole, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -652,20 +810,27 @@ func (q *Queries) AnalyticsTeamByRole(ctx context.Context) ([]AnalyticsTeamByRol
const AnalyticsTeamByStatus = `-- name: AnalyticsTeamByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(tm.status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM team_members
GROUP BY status
FROM team_members tm
WHERE ($1::timestamptz IS NULL OR tm.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR tm.created_at < $2::timestamptz)
GROUP BY tm.status
ORDER BY count DESC
`
type AnalyticsTeamByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsTeamByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsTeamByStatus(ctx context.Context) ([]AnalyticsTeamByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsTeamByStatus)
func (q *Queries) AnalyticsTeamByStatus(ctx context.Context, arg AnalyticsTeamByStatusParams) ([]AnalyticsTeamByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsTeamByStatus, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -688,14 +853,21 @@ const AnalyticsTeamSummary = `-- name: AnalyticsTeamSummary :one
SELECT
COUNT(*)::bigint AS total_members
FROM team_members
FROM team_members tm
WHERE ($1::timestamptz IS NULL OR tm.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR tm.created_at < $2::timestamptz)
`
type AnalyticsTeamSummaryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
// =====================
// Team Analytics
// =====================
func (q *Queries) AnalyticsTeamSummary(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, AnalyticsTeamSummary)
func (q *Queries) AnalyticsTeamSummary(ctx context.Context, arg AnalyticsTeamSummaryParams) (int64, error) {
row := q.db.QueryRow(ctx, AnalyticsTeamSummary, arg.RangeStart, arg.RangeEnd)
var total_members int64
err := row.Scan(&total_members)
return total_members, err
@ -706,22 +878,36 @@ SELECT
d.date,
COUNT(u.id)::bigint AS count
FROM generate_series(
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
$1::date,
$2::date,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN users u ON u.created_at::date = d.date
AND ($3::timestamptz IS NULL OR u.created_at >= $3::timestamptz)
AND ($4::timestamptz IS NULL OR u.created_at < $4::timestamptz)
GROUP BY d.date
ORDER BY d.date
`
type AnalyticsUserRegistrationsLast30DaysParams struct {
SeriesStart pgtype.Date `json:"series_start"`
SeriesEnd pgtype.Date `json:"series_end"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUserRegistrationsLast30DaysRow struct {
Date interface{} `json:"date"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUserRegistrationsLast30Days(ctx context.Context) ([]AnalyticsUserRegistrationsLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUserRegistrationsLast30Days)
func (q *Queries) AnalyticsUserRegistrationsLast30Days(ctx context.Context, arg AnalyticsUserRegistrationsLast30DaysParams) ([]AnalyticsUserRegistrationsLast30DaysRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUserRegistrationsLast30Days,
arg.SeriesStart,
arg.SeriesEnd,
arg.RangeStart,
arg.RangeEnd,
)
if err != nil {
return nil, err
}
@ -742,20 +928,27 @@ func (q *Queries) AnalyticsUserRegistrationsLast30Days(ctx context.Context) ([]A
const AnalyticsUsersByAgeGroup = `-- name: AnalyticsUsersByAgeGroup :many
SELECT
COALESCE(age_group, 'unknown') AS age_group,
COALESCE(u.age_group, 'unknown') AS age_group,
COUNT(*)::bigint AS count
FROM users
GROUP BY age_group
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.age_group
ORDER BY count DESC
`
type AnalyticsUsersByAgeGroupParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByAgeGroupRow struct {
AgeGroup string `json:"age_group"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByAgeGroup(ctx context.Context) ([]AnalyticsUsersByAgeGroupRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByAgeGroup)
func (q *Queries) AnalyticsUsersByAgeGroup(ctx context.Context, arg AnalyticsUsersByAgeGroupParams) ([]AnalyticsUsersByAgeGroupRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByAgeGroup, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -776,20 +969,27 @@ func (q *Queries) AnalyticsUsersByAgeGroup(ctx context.Context) ([]AnalyticsUser
const AnalyticsUsersByKnowledgeLevel = `-- name: AnalyticsUsersByKnowledgeLevel :many
SELECT
COALESCE(knowledge_level, 'unknown') AS knowledge_level,
COALESCE(u.knowledge_level, 'unknown') AS knowledge_level,
COUNT(*)::bigint AS count
FROM users
GROUP BY knowledge_level
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.knowledge_level
ORDER BY count DESC
`
type AnalyticsUsersByKnowledgeLevelParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByKnowledgeLevelRow struct {
KnowledgeLevel string `json:"knowledge_level"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByKnowledgeLevel(ctx context.Context) ([]AnalyticsUsersByKnowledgeLevelRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByKnowledgeLevel)
func (q *Queries) AnalyticsUsersByKnowledgeLevel(ctx context.Context, arg AnalyticsUsersByKnowledgeLevelParams) ([]AnalyticsUsersByKnowledgeLevelRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByKnowledgeLevel, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -810,20 +1010,27 @@ func (q *Queries) AnalyticsUsersByKnowledgeLevel(ctx context.Context) ([]Analyti
const AnalyticsUsersByRegion = `-- name: AnalyticsUsersByRegion :many
SELECT
COALESCE(region, 'unknown') AS region,
COALESCE(u.region, 'unknown') AS region,
COUNT(*)::bigint AS count
FROM users
GROUP BY region
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.region
ORDER BY count DESC
`
type AnalyticsUsersByRegionParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByRegionRow struct {
Region string `json:"region"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByRegion(ctx context.Context) ([]AnalyticsUsersByRegionRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByRegion)
func (q *Queries) AnalyticsUsersByRegion(ctx context.Context, arg AnalyticsUsersByRegionParams) ([]AnalyticsUsersByRegionRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByRegion, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -844,20 +1051,27 @@ func (q *Queries) AnalyticsUsersByRegion(ctx context.Context) ([]AnalyticsUsersB
const AnalyticsUsersByRole = `-- name: AnalyticsUsersByRole :many
SELECT
COALESCE(role, 'unknown') AS role,
COALESCE(u.role, 'unknown') AS role,
COUNT(*)::bigint AS count
FROM users
GROUP BY role
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.role
ORDER BY count DESC
`
type AnalyticsUsersByRoleParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByRoleRow struct {
Role string `json:"role"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByRole(ctx context.Context) ([]AnalyticsUsersByRoleRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByRole)
func (q *Queries) AnalyticsUsersByRole(ctx context.Context, arg AnalyticsUsersByRoleParams) ([]AnalyticsUsersByRoleRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByRole, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -878,20 +1092,27 @@ func (q *Queries) AnalyticsUsersByRole(ctx context.Context) ([]AnalyticsUsersByR
const AnalyticsUsersByStatus = `-- name: AnalyticsUsersByStatus :many
SELECT
COALESCE(status, 'unknown') AS status,
COALESCE(u.status, 'unknown') AS status,
COUNT(*)::bigint AS count
FROM users
GROUP BY status
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY u.status
ORDER BY count DESC
`
type AnalyticsUsersByStatusParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByStatusRow struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByStatus(ctx context.Context) ([]AnalyticsUsersByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByStatus)
func (q *Queries) AnalyticsUsersByStatus(ctx context.Context, arg AnalyticsUsersByStatusParams) ([]AnalyticsUsersByStatusRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByStatus, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
@ -915,12 +1136,26 @@ const AnalyticsUsersSummary = `-- name: AnalyticsUsersSummary :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE)::bigint AS new_today,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '7 days')::bigint AS new_this_week,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days')::bigint AS new_this_month
FROM users
COUNT(*) FILTER (WHERE u.created_at::date = $1::date)::bigint AS new_today,
COUNT(*) FILTER (
WHERE u.created_at::date >= $1::date - INTERVAL '6 days'
AND u.created_at::date <= $1::date
)::bigint AS new_this_week,
COUNT(*) FILTER (
WHERE u.created_at::date >= $1::date - INTERVAL '29 days'
AND u.created_at::date <= $1::date
)::bigint AS new_this_month
FROM users u
WHERE ($2::timestamptz IS NULL OR u.created_at >= $2::timestamptz)
AND ($3::timestamptz IS NULL OR u.created_at < $3::timestamptz)
`
type AnalyticsUsersSummaryParams struct {
RefDate pgtype.Date `json:"ref_date"`
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersSummaryRow struct {
Total int64 `json:"total"`
NewToday int64 `json:"new_today"`
@ -929,13 +1164,25 @@ type AnalyticsUsersSummaryRow struct {
}
// =====================
// Analytics
// Analytics (date-filtered)
// =====================
// Shared optional params (nullable = all-time):
//
// range_start, range_end (exclusive upper bound)
//
// Required chart params:
//
// series_start, series_end (inclusive dates)
//
// Relative window anchor:
//
// ref_date (inclusive date used for new_today/week/month)
//
// =====================
// User Analytics
// =====================
func (q *Queries) AnalyticsUsersSummary(ctx context.Context) (AnalyticsUsersSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsUsersSummary)
func (q *Queries) AnalyticsUsersSummary(ctx context.Context, arg AnalyticsUsersSummaryParams) (AnalyticsUsersSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsUsersSummary, arg.RefDate, arg.RangeStart, arg.RangeEnd)
var i AnalyticsUsersSummaryRow
err := row.Scan(
&i.Total,

View File

@ -101,6 +101,17 @@ type ExamPrepUnitModuleLesson struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Faq struct {
ID int64 `json:"id"`
Question string `json:"question"`
Answer string `json:"answer"`
Category pgtype.Text `json:"category"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type GlobalSetting struct {
Key string `json:"key"`
Value string `json:"value"`

View File

@ -110,6 +110,7 @@ type AnalyticsTeamSection struct {
type AnalyticsDashboard struct {
GeneratedAt time.Time `json:"generated_at"`
DateFilter AnalyticsDateFilter `json:"date_filter"`
Users AnalyticsUsersSection `json:"users"`
Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"`
Payments AnalyticsPaymentsSection `json:"payments"`

View File

@ -0,0 +1,158 @@
package domain
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
const (
AnalyticsFilterAllTime = "all_time"
AnalyticsFilterYear = "year"
AnalyticsFilterYearMonth = "year_month"
AnalyticsFilterCustom = "custom"
)
// AnalyticsDateFilter describes the effective reporting window for dashboard analytics.
type AnalyticsDateFilter struct {
Mode string `json:"mode"`
Year *int `json:"year,omitempty"`
Month *int `json:"month,omitempty"`
From *time.Time `json:"from,omitempty"`
To *time.Time `json:"to,omitempty"`
RangeStart *time.Time `json:"range_start,omitempty"`
RangeEnd *time.Time `json:"range_end,omitempty"`
SeriesStart time.Time `json:"series_start"`
SeriesEnd time.Time `json:"series_end"`
RefDate time.Time `json:"ref_date"`
}
// ParseAnalyticsDateFilter resolves dashboard date filters from query params.
//
// Supported:
// - (none) => all-time totals; charts default to last 30 days
// - year=2025 => calendar year
// - year=2025&month=3 => calendar month
// - from=YYYY-MM-DD&to=YYYY-MM-DD => inclusive custom range (to required with from)
func ParseAnalyticsDateFilter(c *fiber.Ctx) (AnalyticsDateFilter, error) {
now := time.Now().UTC()
today := dateOnlyUTC(now)
fromRaw := strings.TrimSpace(c.Query("from"))
toRaw := strings.TrimSpace(c.Query("to"))
yearRaw := strings.TrimSpace(c.Query("year"))
monthRaw := strings.TrimSpace(c.Query("month"))
if fromRaw != "" || toRaw != "" {
if fromRaw == "" || toRaw == "" {
return AnalyticsDateFilter{}, fmt.Errorf("both from and to are required for a custom date range")
}
from, err := parseAnalyticsDate(fromRaw)
if err != nil {
return AnalyticsDateFilter{}, fmt.Errorf("invalid from date: %w", err)
}
to, err := parseAnalyticsDate(toRaw)
if err != nil {
return AnalyticsDateFilter{}, fmt.Errorf("invalid to date: %w", err)
}
if to.Before(from) {
return AnalyticsDateFilter{}, fmt.Errorf("to must be on or after from")
}
rangeStart := from
rangeEnd := to.AddDate(0, 0, 1)
ref := to
if today.Before(to) {
ref = today
}
return AnalyticsDateFilter{
Mode: AnalyticsFilterCustom,
From: &from,
To: &to,
RangeStart: &rangeStart,
RangeEnd: &rangeEnd,
SeriesStart: from,
SeriesEnd: to,
RefDate: ref,
}, nil
}
if yearRaw != "" {
year, err := strconv.Atoi(yearRaw)
if err != nil || year < 2000 || year > 2100 {
return AnalyticsDateFilter{}, fmt.Errorf("year must be between 2000 and 2100")
}
if monthRaw == "" {
start := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC)
endExclusive := start.AddDate(1, 0, 0)
lastDay := endExclusive.AddDate(0, 0, -1)
ref := lastDay
if today.Year() == year && !today.After(lastDay) {
ref = today
}
return AnalyticsDateFilter{
Mode: AnalyticsFilterYear,
Year: &year,
RangeStart: &start,
RangeEnd: &endExclusive,
SeriesStart: start,
SeriesEnd: lastDay,
RefDate: ref,
}, nil
}
month, err := strconv.Atoi(monthRaw)
if err != nil || month < 1 || month > 12 {
return AnalyticsDateFilter{}, fmt.Errorf("month must be between 1 and 12")
}
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
endExclusive := start.AddDate(0, 1, 0)
lastDay := endExclusive.AddDate(0, 0, -1)
ref := lastDay
if today.Year() == year && int(today.Month()) == month && !today.After(lastDay) {
ref = today
}
return AnalyticsDateFilter{
Mode: AnalyticsFilterYearMonth,
Year: &year,
Month: &month,
RangeStart: &start,
RangeEnd: &endExclusive,
SeriesStart: start,
SeriesEnd: lastDay,
RefDate: ref,
}, nil
}
if monthRaw != "" {
return AnalyticsDateFilter{}, fmt.Errorf("month requires year")
}
seriesEnd := today
seriesStart := seriesEnd.AddDate(0, 0, -29)
return AnalyticsDateFilter{
Mode: AnalyticsFilterAllTime,
SeriesStart: seriesStart,
SeriesEnd: seriesEnd,
RefDate: today,
}, nil
}
func parseAnalyticsDate(raw string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return dateOnlyUTC(t), nil
}
t, err := time.Parse("2006-01-02", raw)
if err != nil {
return time.Time{}, err
}
return dateOnlyUTC(t), nil
}
func dateOnlyUTC(t time.Time) time.Time {
t = t.UTC()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}

View File

@ -0,0 +1,78 @@
package domain
import (
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
)
func TestParseAnalyticsDateFilter_allTime(t *testing.T) {
app := fiber.New()
var got AnalyticsDateFilter
app.Get("/", func(c *fiber.Ctx) error {
var err error
got, err = ParseAnalyticsDateFilter(c)
return err
})
req := httptest.NewRequest("GET", "/", nil)
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if got.Mode != AnalyticsFilterAllTime {
t.Fatalf("mode=%q", got.Mode)
}
if got.RangeStart != nil || got.RangeEnd != nil {
t.Fatal("expected no range bounds for all-time")
}
}
func TestParseAnalyticsDateFilter_yearMonth(t *testing.T) {
app := fiber.New()
var got AnalyticsDateFilter
app.Get("/", func(c *fiber.Ctx) error {
var err error
got, err = ParseAnalyticsDateFilter(c)
return err
})
req := httptest.NewRequest("GET", "/?year=2025&month=3", nil)
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if got.Mode != AnalyticsFilterYearMonth {
t.Fatalf("mode=%q", got.Mode)
}
if got.RangeStart == nil || got.RangeEnd == nil {
t.Fatal("expected range bounds")
}
wantStart := time.Date(2025, time.March, 1, 0, 0, 0, 0, time.UTC)
wantEnd := time.Date(2025, time.April, 1, 0, 0, 0, 0, time.UTC)
if !got.RangeStart.Equal(wantStart) || !got.RangeEnd.Equal(wantEnd) {
t.Fatalf("range=%v..%v", got.RangeStart, got.RangeEnd)
}
}
func TestParseAnalyticsDateFilter_custom(t *testing.T) {
app := fiber.New()
var got AnalyticsDateFilter
app.Get("/", func(c *fiber.Ctx) error {
var err error
got, err = ParseAnalyticsDateFilter(c)
return err
})
req := httptest.NewRequest("GET", "/?from=2025-01-10&to=2025-01-20", nil)
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if got.Mode != AnalyticsFilterCustom {
t.Fatalf("mode=%q", got.Mode)
}
wantEnd := time.Date(2025, time.January, 21, 0, 0, 0, 0, time.UTC)
if got.RangeEnd == nil || !got.RangeEnd.Equal(wantEnd) {
t.Fatalf("range_end=%v", got.RangeEnd)
}
}

View File

@ -15,140 +15,154 @@ func toTime(v interface{}) time.Time {
return time.Time{}
}
// GetAnalyticsDashboard godoc
// @Summary Analytics dashboard
// @Description Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range.
// @Tags analytics
// @Produce json
// @Param year query int false "Calendar year (e.g. 2025)"
// @Param month query int false "Calendar month 1-12 (requires year)"
// @Param from query string false "Custom range start (YYYY-MM-DD or RFC3339)"
// @Param to query string false "Custom range end (YYYY-MM-DD or RFC3339, inclusive)"
// @Success 200 {object} domain.AnalyticsDashboard
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/analytics/dashboard [get]
func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
ctx := c.Context()
filter, err := domain.ParseAnalyticsDateFilter(c)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid date filter",
Error: err.Error(),
})
}
// ── Users ──
usersSummary, err := h.analyticsDB.AnalyticsUsersSummary(ctx)
ctx := c.Context()
p := newAnalyticsQueryParams(filter)
usersSummary, err := h.analyticsDB.AnalyticsUsersSummary(ctx, p.UsersSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user analytics")
}
usersByRole, err := h.analyticsDB.AnalyticsUsersByRole(ctx)
usersByRole, err := h.analyticsDB.AnalyticsUsersByRole(ctx, p.UsersByRole)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by role")
}
usersByStatus, err := h.analyticsDB.AnalyticsUsersByStatus(ctx)
usersByStatus, err := h.analyticsDB.AnalyticsUsersByStatus(ctx, p.UsersByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by status")
}
usersByAge, err := h.analyticsDB.AnalyticsUsersByAgeGroup(ctx)
usersByAge, err := h.analyticsDB.AnalyticsUsersByAgeGroup(ctx, p.UsersByAgeGroup)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by age group")
}
usersByKnowledge, err := h.analyticsDB.AnalyticsUsersByKnowledgeLevel(ctx)
usersByKnowledge, err := h.analyticsDB.AnalyticsUsersByKnowledgeLevel(ctx, p.UsersByKnowledgeLevel)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by knowledge level")
}
usersByRegion, err := h.analyticsDB.AnalyticsUsersByRegion(ctx)
usersByRegion, err := h.analyticsDB.AnalyticsUsersByRegion(ctx, p.UsersByRegion)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by region")
}
userRegs, err := h.analyticsDB.AnalyticsUserRegistrationsLast30Days(ctx)
userRegs, err := h.analyticsDB.AnalyticsUserRegistrationsLast30Days(ctx, p.UserRegistrationsSeries)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user registrations")
}
// ── Subscriptions ──
subsSummary, err := h.analyticsDB.AnalyticsSubscriptionsSummary(ctx)
subsSummary, err := h.analyticsDB.AnalyticsSubscriptionsSummary(ctx, p.SubscriptionsSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscription analytics")
}
subsByStatus, err := h.analyticsDB.AnalyticsSubscriptionsByStatus(ctx)
subsByStatus, err := h.analyticsDB.AnalyticsSubscriptionsByStatus(ctx, p.SubscriptionsByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscriptions by status")
}
revenueByPlan, err := h.analyticsDB.AnalyticsRevenueByPlan(ctx)
revenueByPlan, err := h.analyticsDB.AnalyticsRevenueByPlan(ctx, p.RevenueByPlan)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue by plan")
}
newSubs30, err := h.analyticsDB.AnalyticsNewSubscriptionsLast30Days(ctx)
newSubs30, err := h.analyticsDB.AnalyticsNewSubscriptionsLast30Days(ctx, p.NewSubscriptionsSeries)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch new subscriptions last 30 days")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch new subscriptions time series")
}
// ── Payments ──
paymentsSummary, err := h.analyticsDB.AnalyticsPaymentsSummary(ctx)
paymentsSummary, err := h.analyticsDB.AnalyticsPaymentsSummary(ctx, p.PaymentsSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payment analytics")
}
paymentsByStatus, err := h.analyticsDB.AnalyticsPaymentsByStatus(ctx)
paymentsByStatus, err := h.analyticsDB.AnalyticsPaymentsByStatus(ctx, p.PaymentsByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by status")
}
paymentsByMethod, err := h.analyticsDB.AnalyticsPaymentsByMethod(ctx)
paymentsByMethod, err := h.analyticsDB.AnalyticsPaymentsByMethod(ctx, p.PaymentsByMethod)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by method")
}
revenue30, err := h.analyticsDB.AnalyticsRevenueLast30Days(ctx)
revenue30, err := h.analyticsDB.AnalyticsRevenueLast30Days(ctx, p.RevenueSeries)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue last 30 days")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue time series")
}
// ── Courses ──
courseCounts, err := h.analyticsDB.AnalyticsCourseCounts(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch course analytics")
}
// ── Content ──
questionsCounts, err := h.analyticsDB.AnalyticsQuestionsCounts(ctx)
questionsCounts, err := h.analyticsDB.AnalyticsQuestionsCounts(ctx, p.QuestionsCounts)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch content analytics")
}
questionsByType, err := h.analyticsDB.AnalyticsQuestionsByType(ctx)
questionsByType, err := h.analyticsDB.AnalyticsQuestionsByType(ctx, p.QuestionsByType)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch questions by type")
}
questionSetsByType, err := h.analyticsDB.AnalyticsQuestionSetsByType(ctx)
questionSetsByType, err := h.analyticsDB.AnalyticsQuestionSetsByType(ctx, p.QuestionSetsByType)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch question sets by type")
}
// ── Notifications ──
notifSummary, err := h.analyticsDB.AnalyticsNotificationsSummary(ctx)
notifSummary, err := h.analyticsDB.AnalyticsNotificationsSummary(ctx, p.NotificationsSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notification analytics")
}
notifByChannel, err := h.analyticsDB.AnalyticsNotificationsByChannel(ctx)
notifByChannel, err := h.analyticsDB.AnalyticsNotificationsByChannel(ctx, p.NotificationsChannel)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by channel")
}
notifByType, err := h.analyticsDB.AnalyticsNotificationsByType(ctx)
notifByType, err := h.analyticsDB.AnalyticsNotificationsByType(ctx, p.NotificationsType)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by type")
}
// ── Issues ──
issuesSummary, err := h.analyticsDB.AnalyticsIssuesSummary(ctx)
issuesSummary, err := h.analyticsDB.AnalyticsIssuesSummary(ctx, p.IssuesSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issue analytics")
}
issuesByStatus, err := h.analyticsDB.AnalyticsIssuesByStatus(ctx)
issuesByStatus, err := h.analyticsDB.AnalyticsIssuesByStatus(ctx, p.IssuesByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by status")
}
issuesByType, err := h.analyticsDB.AnalyticsIssuesByType(ctx)
issuesByType, err := h.analyticsDB.AnalyticsIssuesByType(ctx, p.IssuesByType)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by type")
}
// ── Team ──
teamTotal, err := h.analyticsDB.AnalyticsTeamSummary(ctx)
teamTotal, err := h.analyticsDB.AnalyticsTeamSummary(ctx, p.TeamSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team analytics")
}
teamByRole, err := h.analyticsDB.AnalyticsTeamByRole(ctx)
teamByRole, err := h.analyticsDB.AnalyticsTeamByRole(ctx, p.TeamByRole)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by role")
}
teamByStatus, err := h.analyticsDB.AnalyticsTeamByStatus(ctx)
teamByStatus, err := h.analyticsDB.AnalyticsTeamByStatus(ctx, p.TeamByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status")
}
// ── Map to domain types ──
dashboard := domain.AnalyticsDashboard{
GeneratedAt: time.Now(),
GeneratedAt: time.Now().UTC(),
DateFilter: filter,
Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs),
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30),

View File

@ -0,0 +1,113 @@
package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
type analyticsQueryParams struct {
UsersSummary dbgen.AnalyticsUsersSummaryParams
UsersByRole dbgen.AnalyticsUsersByRoleParams
UsersByStatus dbgen.AnalyticsUsersByStatusParams
UsersByAgeGroup dbgen.AnalyticsUsersByAgeGroupParams
UsersByKnowledgeLevel dbgen.AnalyticsUsersByKnowledgeLevelParams
UsersByRegion dbgen.AnalyticsUsersByRegionParams
UserRegistrationsSeries dbgen.AnalyticsUserRegistrationsLast30DaysParams
SubscriptionsSummary dbgen.AnalyticsSubscriptionsSummaryParams
SubscriptionsByStatus dbgen.AnalyticsSubscriptionsByStatusParams
RevenueByPlan dbgen.AnalyticsRevenueByPlanParams
NewSubscriptionsSeries dbgen.AnalyticsNewSubscriptionsLast30DaysParams
PaymentsSummary dbgen.AnalyticsPaymentsSummaryParams
PaymentsByStatus dbgen.AnalyticsPaymentsByStatusParams
PaymentsByMethod dbgen.AnalyticsPaymentsByMethodParams
RevenueSeries dbgen.AnalyticsRevenueLast30DaysParams
QuestionsCounts dbgen.AnalyticsQuestionsCountsParams
QuestionsByType dbgen.AnalyticsQuestionsByTypeParams
QuestionSetsByType dbgen.AnalyticsQuestionSetsByTypeParams
NotificationsSummary dbgen.AnalyticsNotificationsSummaryParams
NotificationsChannel dbgen.AnalyticsNotificationsByChannelParams
NotificationsType dbgen.AnalyticsNotificationsByTypeParams
IssuesSummary dbgen.AnalyticsIssuesSummaryParams
IssuesByStatus dbgen.AnalyticsIssuesByStatusParams
IssuesByType dbgen.AnalyticsIssuesByTypeParams
TeamSummary dbgen.AnalyticsTeamSummaryParams
TeamByRole dbgen.AnalyticsTeamByRoleParams
TeamByStatus dbgen.AnalyticsTeamByStatusParams
}
func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams {
ref := pgAnalyticsDate(f.RefDate)
rs, re := pgAnalyticsTimestamptzPtr(f.RangeStart), pgAnalyticsTimestamptzPtr(f.RangeEnd)
series := dbgen.AnalyticsUserRegistrationsLast30DaysParams{
SeriesStart: pgAnalyticsDate(f.SeriesStart),
SeriesEnd: pgAnalyticsDate(f.SeriesEnd),
RangeStart: rs,
RangeEnd: re,
}
return analyticsQueryParams{
UsersSummary: dbgen.AnalyticsUsersSummaryParams{RefDate: ref, RangeStart: rs, RangeEnd: re},
UsersByRole: dbgen.AnalyticsUsersByRoleParams{RangeStart: rs, RangeEnd: re},
UsersByStatus: dbgen.AnalyticsUsersByStatusParams{RangeStart: rs, RangeEnd: re},
UsersByAgeGroup: dbgen.AnalyticsUsersByAgeGroupParams{RangeStart: rs, RangeEnd: re},
UsersByKnowledgeLevel: dbgen.AnalyticsUsersByKnowledgeLevelParams{RangeStart: rs, RangeEnd: re},
UsersByRegion: dbgen.AnalyticsUsersByRegionParams{RangeStart: rs, RangeEnd: re},
UserRegistrationsSeries: series,
SubscriptionsSummary: dbgen.AnalyticsSubscriptionsSummaryParams{RefDate: ref, RangeStart: rs, RangeEnd: re},
SubscriptionsByStatus: dbgen.AnalyticsSubscriptionsByStatusParams{RangeStart: rs, RangeEnd: re},
RevenueByPlan: dbgen.AnalyticsRevenueByPlanParams{RangeStart: rs, RangeEnd: re},
NewSubscriptionsSeries: dbgen.AnalyticsNewSubscriptionsLast30DaysParams{
SeriesStart: series.SeriesStart,
SeriesEnd: series.SeriesEnd,
RangeStart: rs,
RangeEnd: re,
},
PaymentsSummary: dbgen.AnalyticsPaymentsSummaryParams{RangeStart: rs, RangeEnd: re},
PaymentsByStatus: dbgen.AnalyticsPaymentsByStatusParams{RangeStart: rs, RangeEnd: re},
PaymentsByMethod: dbgen.AnalyticsPaymentsByMethodParams{RangeStart: rs, RangeEnd: re},
RevenueSeries: dbgen.AnalyticsRevenueLast30DaysParams{
SeriesStart: series.SeriesStart,
SeriesEnd: series.SeriesEnd,
RangeStart: rs,
RangeEnd: re,
},
QuestionsCounts: dbgen.AnalyticsQuestionsCountsParams{RangeStart: rs, RangeEnd: re},
QuestionsByType: dbgen.AnalyticsQuestionsByTypeParams{RangeStart: rs, RangeEnd: re},
QuestionSetsByType: dbgen.AnalyticsQuestionSetsByTypeParams{RangeStart: rs, RangeEnd: re},
NotificationsSummary: dbgen.AnalyticsNotificationsSummaryParams{RangeStart: rs, RangeEnd: re},
NotificationsChannel: dbgen.AnalyticsNotificationsByChannelParams{RangeStart: rs, RangeEnd: re},
NotificationsType: dbgen.AnalyticsNotificationsByTypeParams{RangeStart: rs, RangeEnd: re},
IssuesSummary: dbgen.AnalyticsIssuesSummaryParams{RangeStart: rs, RangeEnd: re},
IssuesByStatus: dbgen.AnalyticsIssuesByStatusParams{RangeStart: rs, RangeEnd: re},
IssuesByType: dbgen.AnalyticsIssuesByTypeParams{RangeStart: rs, RangeEnd: re},
TeamSummary: dbgen.AnalyticsTeamSummaryParams{RangeStart: rs, RangeEnd: re},
TeamByRole: dbgen.AnalyticsTeamByRoleParams{RangeStart: rs, RangeEnd: re},
TeamByStatus: dbgen.AnalyticsTeamByStatusParams{RangeStart: rs, RangeEnd: re},
}
}
func pgAnalyticsDate(t time.Time) pgtype.Date {
return pgtype.Date{Time: t.UTC(), Valid: true}
}
func pgAnalyticsTimestamptzPtr(t *time.Time) pgtype.Timestamptz {
if t == nil {
return pgtype.Timestamptz{Valid: false}
}
return pgtype.Timestamptz{Time: t.UTC(), Valid: true}
}