From 024a69b74bb16845db414322faf3aed5ca3d0f87 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 15 May 2026 02:15:15 -0700 Subject: [PATCH] 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 --- db/query/analytics.sql | 243 +++++--- gen/db/analytics.sql.go | 517 +++++++++++++----- gen/db/models.go | 11 + internal/domain/analytics.go | 1 + internal/domain/analytics_filter.go | 158 ++++++ internal/domain/analytics_filter_test.go | 78 +++ .../web_server/handlers/analytics_handler.go | 96 ++-- .../web_server/handlers/analytics_params.go | 113 ++++ 8 files changed, 960 insertions(+), 257 deletions(-) create mode 100644 internal/domain/analytics_filter.go create mode 100644 internal/domain/analytics_filter_test.go create mode 100644 internal/web_server/handlers/analytics_params.go diff --git a/db/query/analytics.sql b/db/query/analytics.sql index 79af4ef..5898f2d 100644 --- a/db/query/analytics.sql +++ b/db/query/analytics.sql @@ -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; diff --git a/gen/db/analytics.sql.go b/gen/db/analytics.sql.go index 8dd0713..3a8bd2a 100644 --- a/gen/db/analytics.sql.go +++ b/gen/db/analytics.sql.go @@ -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, diff --git a/gen/db/models.go b/gen/db/models.go index b288ac1..ab2e7da 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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"` diff --git a/internal/domain/analytics.go b/internal/domain/analytics.go index f843e29..05b73c8 100644 --- a/internal/domain/analytics.go +++ b/internal/domain/analytics.go @@ -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"` diff --git a/internal/domain/analytics_filter.go b/internal/domain/analytics_filter.go new file mode 100644 index 0000000..cf88451 --- /dev/null +++ b/internal/domain/analytics_filter.go @@ -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) +} diff --git a/internal/domain/analytics_filter_test.go b/internal/domain/analytics_filter_test.go new file mode 100644 index 0000000..5fd3b78 --- /dev/null +++ b/internal/domain/analytics_filter_test.go @@ -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) + } +} diff --git a/internal/web_server/handlers/analytics_handler.go b/internal/web_server/handlers/analytics_handler.go index f37251a..c48483b 100644 --- a/internal/web_server/handlers/analytics_handler.go +++ b/internal/web_server/handlers/analytics_handler.go @@ -15,141 +15,155 @@ 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(), - Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs), + 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), Courses: domain.AnalyticsCoursesSection{ diff --git a/internal/web_server/handlers/analytics_params.go b/internal/web_server/handlers/analytics_params.go new file mode 100644 index 0000000..73aefe7 --- /dev/null +++ b/internal/web_server/handlers/analytics_params.go @@ -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} +}