Compare commits
5 Commits
c711df68b9
...
9afc9a4392
| Author | SHA1 | Date | |
|---|---|---|---|
| 9afc9a4392 | |||
| 024a69b74b | |||
| 8bba318372 | |||
| 4ada908555 | |||
| 86ab4e53d4 |
|
|
@ -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;
|
||||
|
|
|
|||
540
docs/PRACTICE_CREATION_API_GUIDE.md
Normal file
540
docs/PRACTICE_CREATION_API_GUIDE.md
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
# Practice Creation API Guide (Lesson Scope)
|
||||
|
||||
This guide provides the full step-by-step API process to create a lesson practice when using:
|
||||
|
||||
- system-defined question types (`MCQ`, `TRUE_FALSE`, `SHORT_ANSWER`, `AUDIO`)
|
||||
- dynamic question types (`DYNAMIC` with `question_type_definition_id` + `dynamic_payload`)
|
||||
|
||||
All endpoints below are relative to `/api/v1` and require bearer authentication.
|
||||
|
||||
---
|
||||
|
||||
## Standard Response Envelope
|
||||
|
||||
Most successful responses follow:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Human-readable message",
|
||||
"data": {},
|
||||
"success": true,
|
||||
"status_code": 200,
|
||||
"metadata": null
|
||||
}
|
||||
```
|
||||
|
||||
Most errors follow:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Error summary",
|
||||
"error": "Detailed reason"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required Permissions
|
||||
|
||||
At minimum, your role should have:
|
||||
|
||||
- `questions.create`
|
||||
- `question_sets.create`
|
||||
- `question_set_items.add`
|
||||
- `practices.create`
|
||||
|
||||
If you create/update dynamic definitions:
|
||||
|
||||
- `questions.update`
|
||||
- `questions.delete` (if you also delete definitions)
|
||||
|
||||
---
|
||||
|
||||
## End-to-End Flow
|
||||
|
||||
1. (Optional) Upload media assets
|
||||
2. Create question(s):
|
||||
- system-defined path, or
|
||||
- dynamic path (definition + question)
|
||||
3. Create `PRACTICE` question set
|
||||
4. Add question(s) to the set
|
||||
5. Create lesson practice linked to that set
|
||||
6. Verify under lesson
|
||||
|
||||
---
|
||||
|
||||
## Step 0 (Optional): Upload Media
|
||||
|
||||
Use this when question content references audio/image URLs.
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /files/upload` (multipart form-data)
|
||||
|
||||
### Form fields
|
||||
|
||||
- `file`: binary
|
||||
- `media_type`: `image` or `audio` or `video`
|
||||
|
||||
### Example success response (shape)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Media uploaded successfully",
|
||||
"data": {
|
||||
"url": "https://your-host/static/uploads/audio/abc.mp3",
|
||||
"object_key": "audio/abc.mp3"
|
||||
},
|
||||
"success": true,
|
||||
"status_code": 201
|
||||
}
|
||||
```
|
||||
|
||||
### Common errors
|
||||
|
||||
- `400` invalid media type/content type
|
||||
- `500` upload/storage failure
|
||||
|
||||
Capture and reuse:
|
||||
|
||||
- `data.url` (or equivalent resolved file URL)
|
||||
|
||||
---
|
||||
|
||||
## Step 1A: Create System-Defined Question(s)
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /questions`
|
||||
|
||||
### Request example (MCQ)
|
||||
|
||||
```json
|
||||
{
|
||||
"question_text": "Choose the correct sentence.",
|
||||
"question_type": "MCQ",
|
||||
"difficulty_level": "EASY",
|
||||
"points": 1,
|
||||
"status": "PUBLISHED",
|
||||
"options": [
|
||||
{ "option_text": "He go to school.", "is_correct": false },
|
||||
{ "option_text": "He goes to school.", "is_correct": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Request example (SHORT_ANSWER)
|
||||
|
||||
```json
|
||||
{
|
||||
"question_text": "Write one sentence using the word 'improve'.",
|
||||
"question_type": "SHORT_ANSWER",
|
||||
"difficulty_level": "MEDIUM",
|
||||
"points": 2,
|
||||
"status": "PUBLISHED",
|
||||
"short_answers": [
|
||||
{ "acceptable_answer": "I want to improve my English.", "match_type": "CASE_INSENSITIVE" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Example success response (shape)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Question created successfully",
|
||||
"data": {
|
||||
"id": 456,
|
||||
"question_text": "Choose the correct sentence.",
|
||||
"question_type": "MCQ",
|
||||
"status": "PUBLISHED"
|
||||
},
|
||||
"success": true,
|
||||
"status_code": 201
|
||||
}
|
||||
```
|
||||
|
||||
### Common errors
|
||||
|
||||
- `400` validation/body errors
|
||||
- `500` create failure
|
||||
|
||||
Capture:
|
||||
|
||||
- `data.id` as `question_id`
|
||||
|
||||
---
|
||||
|
||||
## Step 1B: Dynamic Question Path
|
||||
|
||||
If you use dynamic questions, follow these sub-steps.
|
||||
|
||||
### 1B.1 Validate component-kind selection (optional but recommended)
|
||||
|
||||
#### Endpoint
|
||||
|
||||
`POST /questions/validate-question-type-definition`
|
||||
|
||||
#### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"stimulus_component_kinds": ["QUESTION_TEXT", "AUDIO_PROMPT", "IMAGE"],
|
||||
"response_component_kinds": ["AUDIO_RESPONSE", "TEXT_INPUT"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Success response
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Question type definition is valid",
|
||||
"data": { "valid": true }
|
||||
}
|
||||
```
|
||||
|
||||
#### Error response example
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Invalid question type definition",
|
||||
"error": "response: unknown component kind \"AUDIO_PROMPT\""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1B.2 Create or reuse a dynamic type definition
|
||||
|
||||
#### Endpoint
|
||||
|
||||
`POST /questions/type-definitions`
|
||||
|
||||
#### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "dialogue_audio_avatar_v1",
|
||||
"display_name": "Dialogue Audio + Avatar",
|
||||
"description": "Question text + prompt audio + two avatar images, with audio/text answer",
|
||||
"stimulus_component_kinds": ["QUESTION_TEXT", "AUDIO_PROMPT", "IMAGE"],
|
||||
"response_component_kinds": ["AUDIO_RESPONSE", "TEXT_INPUT"],
|
||||
"stimulus_schema": [
|
||||
{ "id": "question_text", "kind": "QUESTION_TEXT", "required": true },
|
||||
{ "id": "prompt_audio", "kind": "AUDIO_PROMPT", "required": true },
|
||||
{ "id": "speaker_a_avatar", "kind": "IMAGE", "required": true },
|
||||
{ "id": "speaker_b_avatar", "kind": "IMAGE", "required": true }
|
||||
],
|
||||
"response_schema": [
|
||||
{ "id": "answer_audio", "kind": "AUDIO_RESPONSE", "required": true },
|
||||
{ "id": "answer_text", "kind": "TEXT_INPUT", "required": true }
|
||||
],
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
```
|
||||
|
||||
#### Success response example
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Question type definition created",
|
||||
"data": {
|
||||
"id": 123,
|
||||
"key": "dialogue_audio_avatar_v1",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Common errors
|
||||
|
||||
- `400` invalid schema/kinds/mapping
|
||||
- `500` unexpected persistence errors
|
||||
|
||||
Capture:
|
||||
|
||||
- `data.id` as `question_type_definition_id`
|
||||
|
||||
---
|
||||
|
||||
### 1B.3 Create dynamic question
|
||||
|
||||
#### Endpoint
|
||||
|
||||
`POST /questions`
|
||||
|
||||
#### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"question_text": "Listen and respond as Speaker B.",
|
||||
"question_type": "DYNAMIC",
|
||||
"question_type_definition_id": 123,
|
||||
"difficulty_level": "MEDIUM",
|
||||
"points": 2,
|
||||
"status": "PUBLISHED",
|
||||
"dynamic_payload": {
|
||||
"stimulus": [
|
||||
{ "id": "question_text", "kind": "QUESTION_TEXT", "value": "Respond to the conversation." },
|
||||
{ "id": "prompt_audio", "kind": "AUDIO_PROMPT", "value": "https://cdn.example.com/audio/prompt-1.mp3" },
|
||||
{ "id": "speaker_a_avatar", "kind": "IMAGE", "value": "https://cdn.example.com/images/a.webp" },
|
||||
{ "id": "speaker_b_avatar", "kind": "IMAGE", "value": "https://cdn.example.com/images/b.webp" }
|
||||
],
|
||||
"response": [
|
||||
{ "id": "answer_audio", "kind": "AUDIO_RESPONSE", "value": { "instructions": "Record your answer" } },
|
||||
{ "id": "answer_text", "kind": "TEXT_INPUT", "value": { "placeholder": "Type your answer" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Success response example
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Question created successfully",
|
||||
"data": {
|
||||
"id": 789,
|
||||
"question_type": "DYNAMIC",
|
||||
"question_type_definition_id": 123,
|
||||
"status": "PUBLISHED"
|
||||
},
|
||||
"success": true,
|
||||
"status_code": 201
|
||||
}
|
||||
```
|
||||
|
||||
#### Common errors
|
||||
|
||||
- `400` missing/invalid `dynamic_payload`
|
||||
- `400` missing `question_type_definition_id`
|
||||
- `500` persistence failure
|
||||
|
||||
Capture:
|
||||
|
||||
- `data.id` as `question_id`
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create PRACTICE Question Set
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /question-sets`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Lesson 12 - Practice Set",
|
||||
"description": "Question set for lesson-level practice",
|
||||
"set_type": "PRACTICE",
|
||||
"owner_type": "LESSON",
|
||||
"owner_id": 12,
|
||||
"status": "PUBLISHED",
|
||||
"shuffle_questions": false
|
||||
}
|
||||
```
|
||||
|
||||
### Success response example
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Question set created successfully",
|
||||
"data": {
|
||||
"id": 55,
|
||||
"title": "Lesson 12 - Practice Set",
|
||||
"set_type": "PRACTICE",
|
||||
"owner_type": "LESSON",
|
||||
"owner_id": 12,
|
||||
"status": "PUBLISHED"
|
||||
},
|
||||
"success": true,
|
||||
"status_code": 201
|
||||
}
|
||||
```
|
||||
|
||||
### Common errors
|
||||
|
||||
- `400` invalid input
|
||||
- `500` create failure
|
||||
|
||||
Capture:
|
||||
|
||||
- `data.id` as `set_id`
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Add Question(s) to Set
|
||||
|
||||
Run this once per `question_id`.
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /question-sets/:setId/questions`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"question_id": 456,
|
||||
"display_order": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Success response example
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Question added to set successfully",
|
||||
"data": {
|
||||
"id": 901,
|
||||
"set_id": 55,
|
||||
"question_id": 456,
|
||||
"display_order": 1
|
||||
},
|
||||
"success": true,
|
||||
"status_code": 201
|
||||
}
|
||||
```
|
||||
|
||||
### Common errors
|
||||
|
||||
- `400` invalid `setId` or body
|
||||
- `500` link/create failure
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Lesson Practice
|
||||
|
||||
This creates the practice record scoped to lesson.
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /practices`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"parent_kind": "LESSON",
|
||||
"parent_id": 12,
|
||||
"title": "Lesson 12 Conversation Drill",
|
||||
"story_description": "A short two-speaker scenario.",
|
||||
"story_image": "https://cdn.example.com/images/story.webp",
|
||||
"question_set_id": 55,
|
||||
"quick_tips": "Listen carefully before answering."
|
||||
}
|
||||
```
|
||||
|
||||
### Success response example
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Practice created successfully",
|
||||
"data": {
|
||||
"id": 37,
|
||||
"parent_kind": "LESSON",
|
||||
"parent_id": 12,
|
||||
"title": "Lesson 12 Conversation Drill",
|
||||
"question_set_id": 55
|
||||
},
|
||||
"success": true,
|
||||
"status_code": 201
|
||||
}
|
||||
```
|
||||
|
||||
### Common errors
|
||||
|
||||
- `400` validation failed / invalid parent kind
|
||||
- `404` lesson not found
|
||||
- `404` question set not found
|
||||
- `500` create failure
|
||||
|
||||
Capture:
|
||||
|
||||
- `data.id` as `practice_id`
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Verify Practice Under Lesson
|
||||
|
||||
### Endpoint
|
||||
|
||||
`GET /lessons/:id/practices`
|
||||
|
||||
Example:
|
||||
|
||||
`GET /lessons/12/practices`
|
||||
|
||||
### Success response example
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Practices retrieved successfully",
|
||||
"data": {
|
||||
"practices": [
|
||||
{
|
||||
"id": 37,
|
||||
"parent_kind": "LESSON",
|
||||
"parent_id": 12,
|
||||
"title": "Lesson 12 Conversation Drill",
|
||||
"question_set_id": 55
|
||||
}
|
||||
],
|
||||
"total_count": 1,
|
||||
"limit": 20,
|
||||
"offset": 0
|
||||
},
|
||||
"success": true,
|
||||
"status_code": 200
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional Learner Completion Step
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /progress/practices/:id/complete`
|
||||
|
||||
Use `practice_id` as `:id` for current behavior.
|
||||
|
||||
### Success response example
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Practice completed",
|
||||
"success": true,
|
||||
"status_code": 200
|
||||
}
|
||||
```
|
||||
|
||||
### Common errors
|
||||
|
||||
- `403` sequence gating violation
|
||||
- `404` practice not found
|
||||
- `500` completion/persistence failure
|
||||
|
||||
---
|
||||
|
||||
## Quick Checklist (IDs to Carry Forward)
|
||||
|
||||
- From question create: `question_id`
|
||||
- From dynamic definition create (if used): `question_type_definition_id`
|
||||
- From question set create: `set_id`
|
||||
- From practice create: `practice_id`
|
||||
|
||||
---
|
||||
|
||||
## Notes and Pitfalls
|
||||
|
||||
- For dynamic questions, `question_type` must be `DYNAMIC`.
|
||||
- For dynamic questions, both `question_type_definition_id` and `dynamic_payload` are required.
|
||||
- `AUDIO_PROMPT` is stimulus-side; response-side audio uses `AUDIO_RESPONSE`.
|
||||
- `question_set_id` in `POST /practices` must reference an existing set.
|
||||
- For lesson practice always use:
|
||||
- `parent_kind = "LESSON"`
|
||||
- `parent_id = <lesson_id>`
|
||||
- Publish questions and question set (`status = "PUBLISHED"`) if learners must complete immediately.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
158
internal/domain/analytics_filter.go
Normal file
158
internal/domain/analytics_filter.go
Normal 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)
|
||||
}
|
||||
78
internal/domain/analytics_filter_test.go
Normal file
78
internal/domain/analytics_filter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
|
|||
return err
|
||||
}
|
||||
|
||||
if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
|
||||
if err := s.cascadeLMSCompletion(ctx, q, userID, &mod.ID, crs.ID, crs.ProgramID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -62,21 +62,43 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !scope.ModuleID.Valid {
|
||||
return fmt.Errorf("practice %d is not linked to a module", questionSetID)
|
||||
var (
|
||||
moduleID *int64
|
||||
courseID int64
|
||||
)
|
||||
switch {
|
||||
case scope.ModuleID.Valid:
|
||||
mid := scope.ModuleID.Int64
|
||||
moduleID = &mid
|
||||
mod, err := q.GetModuleByID(ctx, mid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
courseID = mod.CourseID
|
||||
case scope.LessonID.Valid:
|
||||
lesson, err := q.GetLessonByID(ctx, scope.LessonID.Int64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mid := lesson.ModuleID
|
||||
moduleID = &mid
|
||||
mod, err := q.GetModuleByID(ctx, mid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
courseID = mod.CourseID
|
||||
case scope.CourseID.Valid:
|
||||
courseID = scope.CourseID.Int64
|
||||
default:
|
||||
return fmt.Errorf("practice %d is not linked to lesson/module/course", questionSetID)
|
||||
}
|
||||
|
||||
mod, err := q.GetModuleByID(ctx, scope.ModuleID.Int64)
|
||||
crs, err := q.GetCourseByID(ctx, courseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
crs, err := q.GetCourseByID(ctx, mod.CourseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
|
||||
if err := s.cascadeLMSCompletion(ctx, q, userID, moduleID, crs.ID, crs.ProgramID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -86,38 +108,40 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID, moduleID, courseID, programID int64) error {
|
||||
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
|
||||
ModuleID: moduleID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
|
||||
ModuleID: toPgInt8(&moduleID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error {
|
||||
if moduleID != nil {
|
||||
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, *moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
|
||||
ModuleID: *moduleID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
|
||||
ModuleID: toPgInt8(moduleID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
|
||||
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
|
||||
if !moduleLessonsComplete || !modulePracticesComplete {
|
||||
return nil
|
||||
}
|
||||
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
|
||||
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
|
||||
if !moduleLessonsComplete || !modulePracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: moduleID}); err != nil {
|
||||
return err
|
||||
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: *moduleID}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
nMods, err := q.CountModulesInCourse(ctx, courseID)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
113
internal/web_server/handlers/analytics_params.go
Normal file
113
internal/web_server/handlers/analytics_params.go
Normal 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}
|
||||
}
|
||||
|
|
@ -1547,35 +1547,34 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), id)
|
||||
if err != nil {
|
||||
// Backward/UX compatibility: accept either question_set.id or lms_practices.id.
|
||||
practice, practiceErr := h.practiceSvc.GetByID(c.Context(), id)
|
||||
if practiceErr != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Practice not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
set, err = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Practice not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
// Prefer LMS practice ID resolution to avoid accidental collisions with question_set IDs.
|
||||
practice, practiceErr := h.practiceSvc.GetByID(c.Context(), id)
|
||||
var set domain.QuestionSet
|
||||
var setErr error
|
||||
if practiceErr == nil {
|
||||
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID)
|
||||
} else {
|
||||
// Backward compatibility: also accept question_set.id directly.
|
||||
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), id)
|
||||
}
|
||||
if !isSequenceGatedPractice(set) || !strings.EqualFold(set.Status, "PUBLISHED") {
|
||||
if setErr != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Practice not found",
|
||||
Error: setErr.Error(),
|
||||
})
|
||||
}
|
||||
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
|
||||
}
|
||||
|
||||
if err := h.enforcePracticeSequenceForStudent(c, set); err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "You must complete previous practices first",
|
||||
Error: err.Error(),
|
||||
})
|
||||
// Enforce sequential gating only for published practices.
|
||||
if strings.EqualFold(set.Status, "PUBLISHED") {
|
||||
if err := h.enforcePracticeSequenceForStudent(c, set); err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "You must complete previous practices first",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.lmsProgressSvc.CompletePracticeForUser(c.Context(), userID, set.ID); err != nil {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user