From aa6194013cabc1e2ab094223f57a71a6675cffc7 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Mon, 16 Feb 2026 08:36:46 -0800 Subject: [PATCH] analytics service + inapp notification websocket fix --- cmd/main.go | 3 + ...00016_notifications_receiver_type.down.sql | 8 + .../000016_notifications_receiver_type.up.sql | 9 + db/query/analytics.sql | 276 +++++ db/query/notification.sql | 4 +- docker-compose.yml | 2 +- gen/db/analytics.sql.go | 947 ++++++++++++++++++ gen/db/models.go | 23 +- gen/db/notification.sql.go | 38 +- internal/domain/analytics.go | 121 +++ internal/domain/notification.go | 21 +- internal/repository/notification.go | 21 +- internal/web_server/app.go | 4 + .../web_server/handlers/analytics_handler.go | 359 +++++++ internal/web_server/handlers/handlers.go | 6 +- .../handlers/notification_handler.go | 29 +- internal/web_server/routes.go | 4 + 17 files changed, 1823 insertions(+), 52 deletions(-) create mode 100644 db/migrations/000016_notifications_receiver_type.down.sql create mode 100644 db/migrations/000016_notifications_receiver_type.up.sql create mode 100644 db/query/analytics.sql create mode 100644 gen/db/analytics.sql.go create mode 100644 internal/domain/analytics.go create mode 100644 internal/web_server/handlers/analytics_handler.go diff --git a/cmd/main.go b/cmd/main.go index ff67b86..ed558a4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,6 +5,7 @@ import ( // "context" _ "Yimaru-Backend/docs" + dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/config" "Yimaru-Backend/internal/domain" customlogger "Yimaru-Backend/internal/logger" @@ -81,6 +82,7 @@ func main() { zap.ReplaceGlobals(domain.MongoDBLogger) store := repository.NewStore(db) + analyticsDB := dbgen.New(db) v := customvalidator.NewCustomValidator(validator.New()) // Initialize services @@ -426,6 +428,7 @@ func main() { recommendationSvc, cfg, domain.MongoDBLogger, + analyticsDB, ) logger.Info("Starting server", "port", cfg.Port) diff --git a/db/migrations/000016_notifications_receiver_type.down.sql b/db/migrations/000016_notifications_receiver_type.down.sql new file mode 100644 index 0000000..f76da1a --- /dev/null +++ b/db/migrations/000016_notifications_receiver_type.down.sql @@ -0,0 +1,8 @@ +-- Remove the receiver_type column. +ALTER TABLE notifications + DROP COLUMN IF EXISTS receiver_type; + +-- Re-add the foreign key constraint on user_id referencing users(id). +ALTER TABLE notifications + ADD CONSTRAINT notifications_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/db/migrations/000016_notifications_receiver_type.up.sql b/db/migrations/000016_notifications_receiver_type.up.sql new file mode 100644 index 0000000..9dcbe91 --- /dev/null +++ b/db/migrations/000016_notifications_receiver_type.up.sql @@ -0,0 +1,9 @@ +-- Drop the foreign key constraint on user_id so notifications can reference +-- either users or team_members depending on receiver_type. +ALTER TABLE notifications + DROP CONSTRAINT IF EXISTS notifications_user_id_fkey; + +-- Add a receiver_type column to distinguish between user and team_member recipients. +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS receiver_type TEXT NOT NULL DEFAULT 'user' + CHECK (receiver_type IN ('user', 'team_member')); diff --git a/db/query/analytics.sql b/db/query/analytics.sql new file mode 100644 index 0000000..727ac17 --- /dev/null +++ b/db/query/analytics.sql @@ -0,0 +1,276 @@ +-- ===================== +-- Analytics +-- ===================== + +-- ===================== +-- User Analytics +-- ===================== + +-- 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; + +-- name: AnalyticsUsersByRole :many +SELECT + COALESCE(role, 'unknown') AS role, + COUNT(*)::bigint AS count +FROM users +GROUP BY role +ORDER BY count DESC; + +-- name: AnalyticsUsersByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count +FROM users +GROUP BY status +ORDER BY count DESC; + +-- name: AnalyticsUsersByAgeGroup :many +SELECT + COALESCE(age_group, 'unknown') AS age_group, + COUNT(*)::bigint AS count +FROM users +GROUP BY age_group +ORDER BY count DESC; + +-- name: AnalyticsUsersByKnowledgeLevel :many +SELECT + COALESCE(knowledge_level, 'unknown') AS knowledge_level, + COUNT(*)::bigint AS count +FROM users +GROUP BY knowledge_level +ORDER BY count DESC; + +-- name: AnalyticsUsersByRegion :many +SELECT + COALESCE(region, 'unknown') AS region, + COUNT(*)::bigint AS count +FROM users +GROUP BY region +ORDER BY count DESC; + +-- name: AnalyticsUserRegistrationsLast30Days :many +SELECT + d.date, + COUNT(u.id)::bigint AS count +FROM generate_series( + CURRENT_DATE - INTERVAL '29 days', + CURRENT_DATE, + INTERVAL '1 day' +) AS d(date) +LEFT JOIN users u ON u.created_at::date = d.date +GROUP BY d.date +ORDER BY d.date; + +-- ===================== +-- Subscription Analytics +-- ===================== + +-- 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; + +-- name: AnalyticsSubscriptionsByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count +FROM user_subscriptions +GROUP BY status +ORDER BY count DESC; + +-- name: AnalyticsRevenueByPlan :many +SELECT + sp.name AS plan_name, + sp.currency, + COUNT(p.id)::bigint AS total_payments, + COALESCE(SUM(p.amount), 0)::float8 AS total_revenue +FROM payments p +JOIN subscription_plans sp ON sp.id = p.plan_id +WHERE p.status = 'SUCCESS' +GROUP BY sp.name, sp.currency +ORDER BY total_revenue DESC; + +-- name: AnalyticsNewSubscriptionsLast30Days :many +SELECT + d.date, + COUNT(us.id)::bigint AS count +FROM generate_series( + CURRENT_DATE - INTERVAL '29 days', + CURRENT_DATE, + INTERVAL '1 day' +) AS d(date) +LEFT JOIN user_subscriptions us ON us.created_at::date = d.date +GROUP BY d.date +ORDER BY d.date; + +-- ===================== +-- Payment Analytics +-- ===================== + +-- 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, + COUNT(*)::bigint AS total_payments, + COUNT(*) FILTER (WHERE status = 'SUCCESS')::bigint AS successful_payments +FROM payments; + +-- name: AnalyticsPaymentsByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count, + COALESCE(SUM(amount), 0)::float8 AS total_amount +FROM payments +GROUP BY status +ORDER BY count DESC; + +-- name: AnalyticsPaymentsByMethod :many +SELECT + COALESCE(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 +ORDER BY count DESC; + +-- name: AnalyticsRevenueLast30Days :many +SELECT + d.date, + COALESCE(SUM(p.amount), 0)::float8 AS total_revenue +FROM generate_series( + CURRENT_DATE - INTERVAL '29 days', + CURRENT_DATE, + INTERVAL '1 day' +) AS d(date) +LEFT JOIN payments p ON p.paid_at::date = d.date AND p.status = 'SUCCESS' +GROUP BY d.date +ORDER BY d.date; + +-- ===================== +-- Course Analytics +-- ===================== + +-- name: AnalyticsCourseCounts :one +SELECT + (SELECT COUNT(*)::bigint FROM course_categories) AS total_categories, + (SELECT COUNT(*)::bigint FROM courses) AS total_courses, + (SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses, + (SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos; + +-- ===================== +-- Content Analytics +-- ===================== + +-- name: AnalyticsQuestionsCounts :one +SELECT + (SELECT COUNT(*)::bigint FROM questions) AS total_questions, + (SELECT COUNT(*)::bigint FROM question_sets) AS total_question_sets; + +-- name: AnalyticsQuestionsByType :many +SELECT + COALESCE(question_type, 'unknown') AS question_type, + COUNT(*)::bigint AS count +FROM questions +GROUP BY question_type +ORDER BY count DESC; + +-- name: AnalyticsQuestionSetsByType :many +SELECT + COALESCE(set_type, 'unknown') AS set_type, + COUNT(*)::bigint AS count +FROM question_sets +GROUP BY set_type +ORDER BY count DESC; + +-- ===================== +-- Notification Analytics +-- ===================== + +-- 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; + +-- name: AnalyticsNotificationsByChannel :many +SELECT + COALESCE(channel, 'unknown') AS channel, + COUNT(*)::bigint AS count +FROM notifications +GROUP BY channel +ORDER BY count DESC; + +-- name: AnalyticsNotificationsByType :many +SELECT + COALESCE(type, 'unknown') AS type, + COUNT(*)::bigint AS count +FROM notifications +GROUP BY type +ORDER BY count DESC; + +-- ===================== +-- Issue Analytics +-- ===================== + +-- name: AnalyticsIssuesSummary :one +SELECT + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE status = 'resolved')::bigint AS resolved, + CASE + WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE status = 'resolved')::float8 / COUNT(*)::float8) + ELSE 0::float8 + END AS resolution_rate +FROM reported_issues; + +-- name: AnalyticsIssuesByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count +FROM reported_issues +GROUP BY status +ORDER BY count DESC; + +-- name: AnalyticsIssuesByType :many +SELECT + COALESCE(issue_type, 'unknown') AS issue_type, + COUNT(*)::bigint AS count +FROM reported_issues +GROUP BY issue_type +ORDER BY count DESC; + +-- ===================== +-- Team Analytics +-- ===================== + +-- name: AnalyticsTeamSummary :one +SELECT + COUNT(*)::bigint AS total_members +FROM team_members; + +-- name: AnalyticsTeamByRole :many +SELECT + COALESCE(team_role, 'unknown') AS team_role, + COUNT(*)::bigint AS count +FROM team_members +GROUP BY team_role +ORDER BY count DESC; + +-- name: AnalyticsTeamByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count +FROM team_members +GROUP BY status +ORDER BY count DESC; diff --git a/db/query/notification.sql b/db/query/notification.sql index cdea088..e0ec869 100644 --- a/db/query/notification.sql +++ b/db/query/notification.sql @@ -1,6 +1,7 @@ -- name: CreateNotification :one INSERT INTO notifications ( user_id, + receiver_type, type, level, channel, @@ -15,7 +16,8 @@ VALUES ( $4, $5, $6, - $7 + $7, + $8 ) RETURNING *; diff --git a/docker-compose.yml b/docker-compose.yml index 2331e39..57075f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: image: dpage/pgadmin4:latest restart: always ports: - - "5050:80" + - "5030:80" environment: PGADMIN_DEFAULT_EMAIL: admin@local.dev PGADMIN_DEFAULT_PASSWORD: admin diff --git a/gen/db/analytics.sql.go b/gen/db/analytics.sql.go new file mode 100644 index 0000000..1cea945 --- /dev/null +++ b/gen/db/analytics.sql.go @@ -0,0 +1,947 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: analytics.sql + +package dbgen + +import ( + "context" +) + +const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one + +SELECT + (SELECT COUNT(*)::bigint FROM course_categories) AS total_categories, + (SELECT COUNT(*)::bigint FROM courses) AS total_courses, + (SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses, + (SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos +` + +type AnalyticsCourseCountsRow struct { + TotalCategories int64 `json:"total_categories"` + TotalCourses int64 `json:"total_courses"` + TotalSubCourses int64 `json:"total_sub_courses"` + TotalVideos int64 `json:"total_videos"` +} + +// ===================== +// Course Analytics +// ===================== +func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCountsRow, error) { + row := q.db.QueryRow(ctx, AnalyticsCourseCounts) + var i AnalyticsCourseCountsRow + err := row.Scan( + &i.TotalCategories, + &i.TotalCourses, + &i.TotalSubCourses, + &i.TotalVideos, + ) + return i, err +} + +const AnalyticsIssuesByStatus = `-- name: AnalyticsIssuesByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count +FROM reported_issues +GROUP BY status +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsIssuesByStatusRow + for rows.Next() { + var i AnalyticsIssuesByStatusRow + if err := rows.Scan(&i.Status, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsIssuesByType = `-- name: AnalyticsIssuesByType :many +SELECT + COALESCE(issue_type, 'unknown') AS issue_type, + COUNT(*)::bigint AS count +FROM reported_issues +GROUP BY issue_type +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsIssuesByTypeRow + for rows.Next() { + var i AnalyticsIssuesByTypeRow + if err := rows.Scan(&i.IssueType, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsIssuesSummary = `-- name: AnalyticsIssuesSummary :one + +SELECT + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE status = 'resolved')::bigint AS resolved, + CASE + WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER (WHERE status = 'resolved')::float8 / COUNT(*)::float8) + ELSE 0::float8 + END AS resolution_rate +FROM reported_issues +` + +type AnalyticsIssuesSummaryRow struct { + Total int64 `json:"total"` + Resolved int64 `json:"resolved"` + ResolutionRate float64 `json:"resolution_rate"` +} + +// ===================== +// Issue Analytics +// ===================== +func (q *Queries) AnalyticsIssuesSummary(ctx context.Context) (AnalyticsIssuesSummaryRow, error) { + row := q.db.QueryRow(ctx, AnalyticsIssuesSummary) + var i AnalyticsIssuesSummaryRow + err := row.Scan(&i.Total, &i.Resolved, &i.ResolutionRate) + return i, err +} + +const AnalyticsNewSubscriptionsLast30Days = `-- name: AnalyticsNewSubscriptionsLast30Days :many +SELECT + d.date, + COUNT(us.id)::bigint AS count +FROM generate_series( + CURRENT_DATE - INTERVAL '29 days', + CURRENT_DATE, + INTERVAL '1 day' +) AS d(date) +LEFT JOIN user_subscriptions us ON us.created_at::date = d.date +GROUP BY d.date +ORDER BY d.date +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsNewSubscriptionsLast30DaysRow + for rows.Next() { + var i AnalyticsNewSubscriptionsLast30DaysRow + if err := rows.Scan(&i.Date, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsNotificationsByChannel = `-- name: AnalyticsNotificationsByChannel :many +SELECT + COALESCE(channel, 'unknown') AS channel, + COUNT(*)::bigint AS count +FROM notifications +GROUP BY channel +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsNotificationsByChannelRow + for rows.Next() { + var i AnalyticsNotificationsByChannelRow + if err := rows.Scan(&i.Channel, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsNotificationsByType = `-- name: AnalyticsNotificationsByType :many +SELECT + COALESCE(type, 'unknown') AS type, + COUNT(*)::bigint AS count +FROM notifications +GROUP BY type +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsNotificationsByTypeRow + for rows.Next() { + var i AnalyticsNotificationsByTypeRow + if err := rows.Scan(&i.Type, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsNotificationsSummary = `-- 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 +` + +type AnalyticsNotificationsSummaryRow struct { + Total int64 `json:"total"` + Read int64 `json:"read"` + Unread int64 `json:"unread"` +} + +// ===================== +// Notification Analytics +// ===================== +func (q *Queries) AnalyticsNotificationsSummary(ctx context.Context) (AnalyticsNotificationsSummaryRow, error) { + row := q.db.QueryRow(ctx, AnalyticsNotificationsSummary) + var i AnalyticsNotificationsSummaryRow + err := row.Scan(&i.Total, &i.Read, &i.Unread) + return i, err +} + +const AnalyticsPaymentsByMethod = `-- name: AnalyticsPaymentsByMethod :many +SELECT + COALESCE(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 +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsPaymentsByMethodRow + for rows.Next() { + var i AnalyticsPaymentsByMethodRow + if err := rows.Scan(&i.PaymentMethod, &i.Count, &i.TotalAmount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsPaymentsByStatus = `-- name: AnalyticsPaymentsByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count, + COALESCE(SUM(amount), 0)::float8 AS total_amount +FROM payments +GROUP BY status +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsPaymentsByStatusRow + for rows.Next() { + var i AnalyticsPaymentsByStatusRow + if err := rows.Scan(&i.Status, &i.Count, &i.TotalAmount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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, + COUNT(*)::bigint AS total_payments, + COUNT(*) FILTER (WHERE status = 'SUCCESS')::bigint AS successful_payments +FROM payments +` + +type AnalyticsPaymentsSummaryRow struct { + TotalRevenue float64 `json:"total_revenue"` + AvgValue float64 `json:"avg_value"` + TotalPayments int64 `json:"total_payments"` + SuccessfulPayments int64 `json:"successful_payments"` +} + +// ===================== +// Payment Analytics +// ===================== +func (q *Queries) AnalyticsPaymentsSummary(ctx context.Context) (AnalyticsPaymentsSummaryRow, error) { + row := q.db.QueryRow(ctx, AnalyticsPaymentsSummary) + var i AnalyticsPaymentsSummaryRow + err := row.Scan( + &i.TotalRevenue, + &i.AvgValue, + &i.TotalPayments, + &i.SuccessfulPayments, + ) + return i, err +} + +const AnalyticsQuestionSetsByType = `-- name: AnalyticsQuestionSetsByType :many +SELECT + COALESCE(set_type, 'unknown') AS set_type, + COUNT(*)::bigint AS count +FROM question_sets +GROUP BY set_type +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsQuestionSetsByTypeRow + for rows.Next() { + var i AnalyticsQuestionSetsByTypeRow + if err := rows.Scan(&i.SetType, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsQuestionsByType = `-- name: AnalyticsQuestionsByType :many +SELECT + COALESCE(question_type, 'unknown') AS question_type, + COUNT(*)::bigint AS count +FROM questions +GROUP BY question_type +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsQuestionsByTypeRow + for rows.Next() { + var i AnalyticsQuestionsByTypeRow + if err := rows.Scan(&i.QuestionType, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsQuestionsCounts = `-- name: AnalyticsQuestionsCounts :one + +SELECT + (SELECT COUNT(*)::bigint FROM questions) AS total_questions, + (SELECT COUNT(*)::bigint FROM question_sets) AS total_question_sets +` + +type AnalyticsQuestionsCountsRow struct { + TotalQuestions int64 `json:"total_questions"` + TotalQuestionSets int64 `json:"total_question_sets"` +} + +// ===================== +// Content Analytics +// ===================== +func (q *Queries) AnalyticsQuestionsCounts(ctx context.Context) (AnalyticsQuestionsCountsRow, error) { + row := q.db.QueryRow(ctx, AnalyticsQuestionsCounts) + var i AnalyticsQuestionsCountsRow + err := row.Scan(&i.TotalQuestions, &i.TotalQuestionSets) + return i, err +} + +const AnalyticsRevenueByPlan = `-- name: AnalyticsRevenueByPlan :many +SELECT + sp.name AS plan_name, + sp.currency, + COUNT(p.id)::bigint AS total_payments, + COALESCE(SUM(p.amount), 0)::float8 AS total_revenue +FROM payments p +JOIN subscription_plans sp ON sp.id = p.plan_id +WHERE p.status = 'SUCCESS' +GROUP BY sp.name, sp.currency +ORDER BY total_revenue DESC +` + +type AnalyticsRevenueByPlanRow struct { + PlanName string `json:"plan_name"` + Currency string `json:"currency"` + TotalPayments int64 `json:"total_payments"` + TotalRevenue float64 `json:"total_revenue"` +} + +func (q *Queries) AnalyticsRevenueByPlan(ctx context.Context) ([]AnalyticsRevenueByPlanRow, error) { + rows, err := q.db.Query(ctx, AnalyticsRevenueByPlan) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsRevenueByPlanRow + for rows.Next() { + var i AnalyticsRevenueByPlanRow + if err := rows.Scan( + &i.PlanName, + &i.Currency, + &i.TotalPayments, + &i.TotalRevenue, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsRevenueLast30Days = `-- name: AnalyticsRevenueLast30Days :many +SELECT + d.date, + COALESCE(SUM(p.amount), 0)::float8 AS total_revenue +FROM generate_series( + CURRENT_DATE - INTERVAL '29 days', + CURRENT_DATE, + INTERVAL '1 day' +) AS d(date) +LEFT JOIN payments p ON p.paid_at::date = d.date AND p.status = 'SUCCESS' +GROUP BY d.date +ORDER BY d.date +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsRevenueLast30DaysRow + for rows.Next() { + var i AnalyticsRevenueLast30DaysRow + if err := rows.Scan(&i.Date, &i.TotalRevenue); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsSubscriptionsByStatus = `-- name: AnalyticsSubscriptionsByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count +FROM user_subscriptions +GROUP BY status +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsSubscriptionsByStatusRow + for rows.Next() { + var i AnalyticsSubscriptionsByStatusRow + if err := rows.Scan(&i.Status, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsSubscriptionsSummary = `-- 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 +` + +type AnalyticsSubscriptionsSummaryRow struct { + Total int64 `json:"total"` + Active int64 `json:"active"` + NewToday int64 `json:"new_today"` + NewThisWeek int64 `json:"new_this_week"` + NewThisMonth int64 `json:"new_this_month"` +} + +// ===================== +// Subscription Analytics +// ===================== +func (q *Queries) AnalyticsSubscriptionsSummary(ctx context.Context) (AnalyticsSubscriptionsSummaryRow, error) { + row := q.db.QueryRow(ctx, AnalyticsSubscriptionsSummary) + var i AnalyticsSubscriptionsSummaryRow + err := row.Scan( + &i.Total, + &i.Active, + &i.NewToday, + &i.NewThisWeek, + &i.NewThisMonth, + ) + return i, err +} + +const AnalyticsTeamByRole = `-- name: AnalyticsTeamByRole :many +SELECT + COALESCE(team_role, 'unknown') AS team_role, + COUNT(*)::bigint AS count +FROM team_members +GROUP BY team_role +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsTeamByRoleRow + for rows.Next() { + var i AnalyticsTeamByRoleRow + if err := rows.Scan(&i.TeamRole, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsTeamByStatus = `-- name: AnalyticsTeamByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count +FROM team_members +GROUP BY status +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsTeamByStatusRow + for rows.Next() { + var i AnalyticsTeamByStatusRow + if err := rows.Scan(&i.Status, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsTeamSummary = `-- name: AnalyticsTeamSummary :one + +SELECT + COUNT(*)::bigint AS total_members +FROM team_members +` + +// ===================== +// Team Analytics +// ===================== +func (q *Queries) AnalyticsTeamSummary(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, AnalyticsTeamSummary) + var total_members int64 + err := row.Scan(&total_members) + return total_members, err +} + +const AnalyticsUserRegistrationsLast30Days = `-- name: AnalyticsUserRegistrationsLast30Days :many +SELECT + d.date, + COUNT(u.id)::bigint AS count +FROM generate_series( + CURRENT_DATE - INTERVAL '29 days', + CURRENT_DATE, + INTERVAL '1 day' +) AS d(date) +LEFT JOIN users u ON u.created_at::date = d.date +GROUP BY d.date +ORDER BY d.date +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUserRegistrationsLast30DaysRow + for rows.Next() { + var i AnalyticsUserRegistrationsLast30DaysRow + if err := rows.Scan(&i.Date, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsUsersByAgeGroup = `-- name: AnalyticsUsersByAgeGroup :many +SELECT + COALESCE(age_group, 'unknown') AS age_group, + COUNT(*)::bigint AS count +FROM users +GROUP BY age_group +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUsersByAgeGroupRow + for rows.Next() { + var i AnalyticsUsersByAgeGroupRow + if err := rows.Scan(&i.AgeGroup, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsUsersByKnowledgeLevel = `-- name: AnalyticsUsersByKnowledgeLevel :many +SELECT + COALESCE(knowledge_level, 'unknown') AS knowledge_level, + COUNT(*)::bigint AS count +FROM users +GROUP BY knowledge_level +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUsersByKnowledgeLevelRow + for rows.Next() { + var i AnalyticsUsersByKnowledgeLevelRow + if err := rows.Scan(&i.KnowledgeLevel, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsUsersByRegion = `-- name: AnalyticsUsersByRegion :many +SELECT + COALESCE(region, 'unknown') AS region, + COUNT(*)::bigint AS count +FROM users +GROUP BY region +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUsersByRegionRow + for rows.Next() { + var i AnalyticsUsersByRegionRow + if err := rows.Scan(&i.Region, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsUsersByRole = `-- name: AnalyticsUsersByRole :many +SELECT + COALESCE(role, 'unknown') AS role, + COUNT(*)::bigint AS count +FROM users +GROUP BY role +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUsersByRoleRow + for rows.Next() { + var i AnalyticsUsersByRoleRow + if err := rows.Scan(&i.Role, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsUsersByStatus = `-- name: AnalyticsUsersByStatus :many +SELECT + COALESCE(status, 'unknown') AS status, + COUNT(*)::bigint AS count +FROM users +GROUP BY status +ORDER BY count DESC +` + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUsersByStatusRow + for rows.Next() { + var i AnalyticsUsersByStatusRow + if err := rows.Scan(&i.Status, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 +` + +type AnalyticsUsersSummaryRow struct { + Total int64 `json:"total"` + NewToday int64 `json:"new_today"` + NewThisWeek int64 `json:"new_this_week"` + NewThisMonth int64 `json:"new_this_month"` +} + +// ===================== +// Analytics +// ===================== +// ===================== +// User Analytics +// ===================== +func (q *Queries) AnalyticsUsersSummary(ctx context.Context) (AnalyticsUsersSummaryRow, error) { + row := q.db.QueryRow(ctx, AnalyticsUsersSummary) + var i AnalyticsUsersSummaryRow + err := row.Scan( + &i.Total, + &i.NewToday, + &i.NewThisWeek, + &i.NewThisMonth, + ) + return i, err +} diff --git a/gen/db/models.go b/gen/db/models.go index 17c6127..08546b3 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -66,17 +66,18 @@ type ModuleToSubCourse struct { } type Notification struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - Type string `json:"type"` - Level string `json:"level"` - Channel pgtype.Text `json:"channel"` - Title string `json:"title"` - Message string `json:"message"` - Payload []byte `json:"payload"` - IsRead bool `json:"is_read"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - ReadAt pgtype.Timestamptz `json:"read_at"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Type string `json:"type"` + Level string `json:"level"` + Channel pgtype.Text `json:"channel"` + Title string `json:"title"` + Message string `json:"message"` + Payload []byte `json:"payload"` + IsRead bool `json:"is_read"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ReadAt pgtype.Timestamptz `json:"read_at"` + ReceiverType string `json:"receiver_type"` } type Otp struct { diff --git a/gen/db/notification.sql.go b/gen/db/notification.sql.go index 3534171..4e90591 100644 --- a/gen/db/notification.sql.go +++ b/gen/db/notification.sql.go @@ -28,6 +28,7 @@ func (q *Queries) CountUnreadNotifications(ctx context.Context, userID int64) (i const CreateNotification = `-- name: CreateNotification :one INSERT INTO notifications ( user_id, + receiver_type, type, level, channel, @@ -42,24 +43,27 @@ VALUES ( $4, $5, $6, - $7 + $7, + $8 ) -RETURNING id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at +RETURNING id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type ` type CreateNotificationParams struct { - UserID int64 `json:"user_id"` - Type string `json:"type"` - Level string `json:"level"` - Channel pgtype.Text `json:"channel"` - Title string `json:"title"` - Message string `json:"message"` - Payload []byte `json:"payload"` + UserID int64 `json:"user_id"` + ReceiverType string `json:"receiver_type"` + Type string `json:"type"` + Level string `json:"level"` + Channel pgtype.Text `json:"channel"` + Title string `json:"title"` + Message string `json:"message"` + Payload []byte `json:"payload"` } func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error) { row := q.db.QueryRow(ctx, CreateNotification, arg.UserID, + arg.ReceiverType, arg.Type, arg.Level, arg.Channel, @@ -80,6 +84,7 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification &i.IsRead, &i.CreatedAt, &i.ReadAt, + &i.ReceiverType, ) return i, err } @@ -95,7 +100,7 @@ func (q *Queries) DeleteUserNotifications(ctx context.Context, userID int64) err } const GetAllNotifications = `-- name: GetAllNotifications :many -SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at +SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type FROM notifications ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -127,6 +132,7 @@ func (q *Queries) GetAllNotifications(ctx context.Context, arg GetAllNotificatio &i.IsRead, &i.CreatedAt, &i.ReadAt, + &i.ReceiverType, ); err != nil { return nil, err } @@ -139,7 +145,7 @@ func (q *Queries) GetAllNotifications(ctx context.Context, arg GetAllNotificatio } const GetNotification = `-- name: GetNotification :one -SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at +SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type FROM notifications WHERE id = $1 LIMIT 1 @@ -160,6 +166,7 @@ func (q *Queries) GetNotification(ctx context.Context, id int64) (Notification, &i.IsRead, &i.CreatedAt, &i.ReadAt, + &i.ReceiverType, ) return i, err } @@ -190,7 +197,7 @@ func (q *Queries) GetUserNotificationCount(ctx context.Context, userID int64) (i } const GetUserNotifications = `-- name: GetUserNotifications :many -SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at +SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type FROM notifications WHERE user_id = $1 ORDER BY created_at DESC @@ -224,6 +231,7 @@ func (q *Queries) GetUserNotifications(ctx context.Context, arg GetUserNotificat &i.IsRead, &i.CreatedAt, &i.ReadAt, + &i.ReceiverType, ); err != nil { return nil, err } @@ -266,7 +274,7 @@ UPDATE notifications SET is_read = TRUE, read_at = NOW() WHERE id = $1 -RETURNING id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at +RETURNING id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type ` func (q *Queries) MarkNotificationAsRead(ctx context.Context, id int64) (Notification, error) { @@ -284,6 +292,7 @@ func (q *Queries) MarkNotificationAsRead(ctx context.Context, id int64) (Notific &i.IsRead, &i.CreatedAt, &i.ReadAt, + &i.ReceiverType, ) return i, err } @@ -293,7 +302,7 @@ UPDATE notifications SET is_read = FALSE, read_at = NULL WHERE id = $1 -RETURNING id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at +RETURNING id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type ` func (q *Queries) MarkNotificationAsUnread(ctx context.Context, id int64) (Notification, error) { @@ -311,6 +320,7 @@ func (q *Queries) MarkNotificationAsUnread(ctx context.Context, id int64) (Notif &i.IsRead, &i.CreatedAt, &i.ReadAt, + &i.ReceiverType, ) return i, err } diff --git a/internal/domain/analytics.go b/internal/domain/analytics.go new file mode 100644 index 0000000..f843e29 --- /dev/null +++ b/internal/domain/analytics.go @@ -0,0 +1,121 @@ +package domain + +import "time" + +type AnalyticsLabelCount struct { + Label string `json:"label"` + Count int64 `json:"count"` +} + +type AnalyticsLabelAmount struct { + Label string `json:"label"` + Count int64 `json:"count"` + Amount float64 `json:"amount"` +} + +type AnalyticsRevenueByPlan struct { + PlanName string `json:"plan_name"` + Currency string `json:"currency"` + Revenue float64 `json:"revenue"` +} + +type AnalyticsTimePoint struct { + Date time.Time `json:"date"` + Count int64 `json:"count"` +} + +type AnalyticsRevenueTimePoint struct { + Date time.Time `json:"date"` + Revenue float64 `json:"revenue"` +} + +type AnalyticsUsersSection struct { + TotalUsers int64 `json:"total_users"` + NewToday int64 `json:"new_today"` + NewWeek int64 `json:"new_week"` + NewMonth int64 `json:"new_month"` + + ByRole []AnalyticsLabelCount `json:"by_role"` + ByStatus []AnalyticsLabelCount `json:"by_status"` + ByAgeGroup []AnalyticsLabelCount `json:"by_age_group"` + ByKnowledgeLevel []AnalyticsLabelCount `json:"by_knowledge_level"` + ByRegion []AnalyticsLabelCount `json:"by_region"` + + RegistrationsLast30Days []AnalyticsTimePoint `json:"registrations_last_30_days"` +} + +type AnalyticsSubscriptionsSection struct { + TotalSubscriptions int64 `json:"total_subscriptions"` + ActiveSubscriptions int64 `json:"active_subscriptions"` + NewToday int64 `json:"new_today"` + NewWeek int64 `json:"new_week"` + NewMonth int64 `json:"new_month"` + + ByStatus []AnalyticsLabelCount `json:"by_status"` + RevenueByPlan []AnalyticsRevenueByPlan `json:"revenue_by_plan"` + + NewSubscriptionsLast30Days []AnalyticsTimePoint `json:"new_subscriptions_last_30_days"` +} + +type AnalyticsPaymentsSection struct { + TotalRevenue float64 `json:"total_revenue"` + AvgTransactionValue float64 `json:"avg_transaction_value"` + TotalPayments int64 `json:"total_payments"` + SuccessfulPayments int64 `json:"successful_payments"` + + ByStatus []AnalyticsLabelAmount `json:"by_status"` + ByMethod []AnalyticsLabelAmount `json:"by_method"` + + RevenueLast30Days []AnalyticsRevenueTimePoint `json:"revenue_last_30_days"` +} + +type AnalyticsCoursesSection struct { + TotalCategories int64 `json:"total_categories"` + TotalCourses int64 `json:"total_courses"` + TotalSubCourses int64 `json:"total_sub_courses"` + TotalVideos int64 `json:"total_videos"` +} + +type AnalyticsContentSection struct { + TotalQuestions int64 `json:"total_questions"` + TotalQuestionSets int64 `json:"total_question_sets"` + + QuestionsByType []AnalyticsLabelCount `json:"questions_by_type"` + QuestionSetsByType []AnalyticsLabelCount `json:"question_sets_by_type"` +} + +type AnalyticsNotificationsSection struct { + TotalSent int64 `json:"total_sent"` + ReadCount int64 `json:"read_count"` + UnreadCount int64 `json:"unread_count"` + + ByChannel []AnalyticsLabelCount `json:"by_channel"` + ByType []AnalyticsLabelCount `json:"by_type"` +} + +type AnalyticsIssuesSection struct { + TotalIssues int64 `json:"total_issues"` + ResolvedIssues int64 `json:"resolved_issues"` + ResolutionRate float64 `json:"resolution_rate"` + + ByStatus []AnalyticsLabelCount `json:"by_status"` + ByType []AnalyticsLabelCount `json:"by_type"` +} + +type AnalyticsTeamSection struct { + TotalMembers int64 `json:"total_members"` + ByRole []AnalyticsLabelCount `json:"by_role"` + ByStatus []AnalyticsLabelCount `json:"by_status"` +} + +type AnalyticsDashboard struct { + GeneratedAt time.Time `json:"generated_at"` + Users AnalyticsUsersSection `json:"users"` + Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"` + Payments AnalyticsPaymentsSection `json:"payments"` + Courses AnalyticsCoursesSection `json:"courses"` + Content AnalyticsContentSection `json:"content"` + Notifications AnalyticsNotificationsSection `json:"notifications"` + Issues AnalyticsIssuesSection `json:"issues"` + Team AnalyticsTeamSection `json:"team"` +} diff --git a/internal/domain/notification.go b/internal/domain/notification.go index d5a5a14..1240441 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -12,6 +12,12 @@ type NotificationLevel string type NotificationErrorSeverity string type NotificationDeliveryStatus string type DeliveryChannel string +type ReceiverType string + +const ( + ReceiverTypeUser ReceiverType = "user" + ReceiverTypeTeamMember ReceiverType = "team_member" +) const ( NOTIFICATION_TYPE_KNOWLEDGE_LEVEL_UPDATE NotificationType = "knowledge_level_update" @@ -30,6 +36,7 @@ const ( NotificationRecieverSideCustomer NotificationRecieverSide = "customer" NotificationRecieverSideCashier NotificationRecieverSide = "cashier" NotificationRecieverSideBranchManager NotificationRecieverSide = "branch_manager" + NotificationRecieverSideTeamMember NotificationRecieverSide = "team_member" NotificationDeliverySchemeBulk NotificationDeliveryScheme = "bulk" NotificationDeliverySchemeSingle NotificationDeliveryScheme = "single" @@ -52,7 +59,7 @@ const ( DeliveryChannelEmail DeliveryChannel = "email" DeliveryChannelSMS DeliveryChannel = "sms" DeliveryChannelPush DeliveryChannel = "push" - DeliveryChannelInApp DeliveryChannel = "in-app" + DeliveryChannelInApp DeliveryChannel = "in_app" ) type NotificationPayload struct { @@ -64,6 +71,7 @@ type NotificationPayload struct { type Notification struct { ID string `json:"id"` RecipientID int64 `json:"recipient_id"` + ReceiverType ReceiverType `json:"receiver_type"` Type NotificationType `json:"type"` Level NotificationLevel `json:"level"` ErrorSeverity NotificationErrorSeverity `json:"error_severity"` @@ -81,6 +89,7 @@ type Notification struct { } type CreateNotification struct { RecipientID int64 `json:"recipient_id"` + ReceiverType ReceiverType `json:"receiver_type"` Type NotificationType `json:"type"` Level NotificationLevel `json:"level"` ErrorSeverity *NotificationErrorSeverity `json:"error_severity"` @@ -109,7 +118,6 @@ func FromJSON(data []byte) (*Notification, error) { } func ReceiverFromRole(role Role) NotificationRecieverSide { - switch role { case RoleAdmin: return NotificationRecieverSideAdmin @@ -123,3 +131,12 @@ func ReceiverFromRole(role Role) NotificationRecieverSide { return "" } } + +func ReceiverTypeFromReciever(reciever NotificationRecieverSide) ReceiverType { + switch reciever { + case NotificationRecieverSideTeamMember: + return ReceiverTypeTeamMember + default: + return ReceiverTypeUser + } +} diff --git a/internal/repository/notification.go b/internal/repository/notification.go index a83cf1a..b78dff2 100644 --- a/internal/repository/notification.go +++ b/internal/repository/notification.go @@ -25,14 +25,20 @@ func (r *Store) CreateNotification( n *domain.Notification, ) (*domain.Notification, error) { + receiverType := string(n.ReceiverType) + if receiverType == "" { + receiverType = string(domain.ReceiverTypeUser) + } + params := dbgen.CreateNotificationParams{ - UserID: n.RecipientID, - Type: string(n.Type), - Level: string(n.Level), - Channel: pgtype.Text{String: string(n.DeliveryChannel)}, - Title: n.Payload.Headline, - Message: n.Payload.Message, - Payload: marshalPayload(n.Payload), + UserID: n.RecipientID, + ReceiverType: receiverType, + Type: string(n.Type), + Level: string(n.Level), + Channel: pgtype.Text{String: string(n.DeliveryChannel), Valid: true}, + Title: n.Payload.Headline, + Message: n.Payload.Message, + Payload: marshalPayload(n.Payload), } dbNotif, err := r.queries.CreateNotification(ctx, params) @@ -178,6 +184,7 @@ func mapDBToDomain(db *dbgen.Notification) *domain.Notification { return &domain.Notification{ ID: strconv.FormatInt(db.ID, 10), RecipientID: db.UserID, + ReceiverType: domain.ReceiverType(db.ReceiverType), Type: domain.NotificationType(db.Type), Level: domain.NotificationLevel(db.Level), DeliveryChannel: channel, diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 47994e6..4fc6720 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -1,6 +1,7 @@ package httpserver import ( + dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/config" activitylogservice "Yimaru-Backend/internal/services/activity_log" "Yimaru-Backend/internal/services/arifpay" @@ -54,6 +55,7 @@ type App struct { JwtConfig jwtutil.JwtConfig Logger *slog.Logger mongoLoggerSvc *zap.Logger + analyticsDB *dbgen.Queries } func NewApp( @@ -77,6 +79,7 @@ func NewApp( recommendationSvc recommendation.RecommendationService, cfg *config.Config, mongoLoggerSvc *zap.Logger, + analyticsDB *dbgen.Queries, ) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, @@ -119,6 +122,7 @@ func NewApp( recommendationSvc: recommendationSvc, cfg: cfg, mongoLoggerSvc: mongoLoggerSvc, + analyticsDB: analyticsDB, } s.initAppRoutes() diff --git a/internal/web_server/handlers/analytics_handler.go b/internal/web_server/handlers/analytics_handler.go new file mode 100644 index 0000000..f37251a --- /dev/null +++ b/internal/web_server/handlers/analytics_handler.go @@ -0,0 +1,359 @@ +package handlers + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "time" + + "github.com/gofiber/fiber/v2" +) + +func toTime(v interface{}) time.Time { + if t, ok := v.(time.Time); ok { + return t + } + return time.Time{} +} + +func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error { + ctx := c.Context() + + // ── Users ── + usersSummary, err := h.analyticsDB.AnalyticsUsersSummary(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user analytics") + } + usersByRole, err := h.analyticsDB.AnalyticsUsersByRole(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by role") + } + usersByStatus, err := h.analyticsDB.AnalyticsUsersByStatus(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by status") + } + usersByAge, err := h.analyticsDB.AnalyticsUsersByAgeGroup(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by age group") + } + usersByKnowledge, err := h.analyticsDB.AnalyticsUsersByKnowledgeLevel(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by knowledge level") + } + usersByRegion, err := h.analyticsDB.AnalyticsUsersByRegion(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by region") + } + userRegs, err := h.analyticsDB.AnalyticsUserRegistrationsLast30Days(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user registrations") + } + + // ── Subscriptions ── + subsSummary, err := h.analyticsDB.AnalyticsSubscriptionsSummary(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscription analytics") + } + subsByStatus, err := h.analyticsDB.AnalyticsSubscriptionsByStatus(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscriptions by status") + } + revenueByPlan, err := h.analyticsDB.AnalyticsRevenueByPlan(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue by plan") + } + newSubs30, err := h.analyticsDB.AnalyticsNewSubscriptionsLast30Days(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch new subscriptions last 30 days") + } + + // ── Payments ── + paymentsSummary, err := h.analyticsDB.AnalyticsPaymentsSummary(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payment analytics") + } + paymentsByStatus, err := h.analyticsDB.AnalyticsPaymentsByStatus(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by status") + } + paymentsByMethod, err := h.analyticsDB.AnalyticsPaymentsByMethod(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by method") + } + revenue30, err := h.analyticsDB.AnalyticsRevenueLast30Days(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue last 30 days") + } + + // ── 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) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch content analytics") + } + questionsByType, err := h.analyticsDB.AnalyticsQuestionsByType(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch questions by type") + } + questionSetsByType, err := h.analyticsDB.AnalyticsQuestionSetsByType(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch question sets by type") + } + + // ── Notifications ── + notifSummary, err := h.analyticsDB.AnalyticsNotificationsSummary(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notification analytics") + } + notifByChannel, err := h.analyticsDB.AnalyticsNotificationsByChannel(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by channel") + } + notifByType, err := h.analyticsDB.AnalyticsNotificationsByType(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by type") + } + + // ── Issues ── + issuesSummary, err := h.analyticsDB.AnalyticsIssuesSummary(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issue analytics") + } + issuesByStatus, err := h.analyticsDB.AnalyticsIssuesByStatus(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by status") + } + issuesByType, err := h.analyticsDB.AnalyticsIssuesByType(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by type") + } + + // ── Team ── + teamTotal, err := h.analyticsDB.AnalyticsTeamSummary(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team analytics") + } + teamByRole, err := h.analyticsDB.AnalyticsTeamByRole(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by role") + } + teamByStatus, err := h.analyticsDB.AnalyticsTeamByStatus(ctx) + 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), + Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), + Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30), + Courses: domain.AnalyticsCoursesSection{ + TotalCategories: courseCounts.TotalCategories, + TotalCourses: courseCounts.TotalCourses, + TotalSubCourses: courseCounts.TotalSubCourses, + TotalVideos: courseCounts.TotalVideos, + }, + Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType), + Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType), + Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType), + Team: mapTeamSection(teamTotal, teamByRole, teamByStatus), + } + + return c.JSON(dashboard) +} + +func mapUsersSection( + summary dbgen.AnalyticsUsersSummaryRow, + byRole []dbgen.AnalyticsUsersByRoleRow, + byStatus []dbgen.AnalyticsUsersByStatusRow, + byAge []dbgen.AnalyticsUsersByAgeGroupRow, + byKnowledge []dbgen.AnalyticsUsersByKnowledgeLevelRow, + byRegion []dbgen.AnalyticsUsersByRegionRow, + regs []dbgen.AnalyticsUserRegistrationsLast30DaysRow, +) domain.AnalyticsUsersSection { + roles := make([]domain.AnalyticsLabelCount, len(byRole)) + for i, r := range byRole { + roles[i] = domain.AnalyticsLabelCount{Label: r.Role, Count: r.Count} + } + statuses := make([]domain.AnalyticsLabelCount, len(byStatus)) + for i, r := range byStatus { + statuses[i] = domain.AnalyticsLabelCount{Label: r.Status, Count: r.Count} + } + ages := make([]domain.AnalyticsLabelCount, len(byAge)) + for i, r := range byAge { + ages[i] = domain.AnalyticsLabelCount{Label: r.AgeGroup, Count: r.Count} + } + knowledge := make([]domain.AnalyticsLabelCount, len(byKnowledge)) + for i, r := range byKnowledge { + knowledge[i] = domain.AnalyticsLabelCount{Label: r.KnowledgeLevel, Count: r.Count} + } + regions := make([]domain.AnalyticsLabelCount, len(byRegion)) + for i, r := range byRegion { + regions[i] = domain.AnalyticsLabelCount{Label: r.Region, Count: r.Count} + } + timePoints := make([]domain.AnalyticsTimePoint, len(regs)) + for i, r := range regs { + timePoints[i] = domain.AnalyticsTimePoint{Date: toTime(r.Date), Count: r.Count} + } + return domain.AnalyticsUsersSection{ + TotalUsers: summary.Total, + NewToday: summary.NewToday, + NewWeek: summary.NewThisWeek, + NewMonth: summary.NewThisMonth, + ByRole: roles, + ByStatus: statuses, + ByAgeGroup: ages, + ByKnowledgeLevel: knowledge, + ByRegion: regions, + RegistrationsLast30Days: timePoints, + } +} + +func mapSubscriptionsSection( + summary dbgen.AnalyticsSubscriptionsSummaryRow, + byStatus []dbgen.AnalyticsSubscriptionsByStatusRow, + byPlan []dbgen.AnalyticsRevenueByPlanRow, + newSubs []dbgen.AnalyticsNewSubscriptionsLast30DaysRow, +) domain.AnalyticsSubscriptionsSection { + statuses := make([]domain.AnalyticsLabelCount, len(byStatus)) + for i, r := range byStatus { + statuses[i] = domain.AnalyticsLabelCount{Label: r.Status, Count: r.Count} + } + plans := make([]domain.AnalyticsRevenueByPlan, len(byPlan)) + for i, r := range byPlan { + plans[i] = domain.AnalyticsRevenueByPlan{PlanName: r.PlanName, Currency: r.Currency, Revenue: r.TotalRevenue} + } + timePoints := make([]domain.AnalyticsTimePoint, len(newSubs)) + for i, r := range newSubs { + timePoints[i] = domain.AnalyticsTimePoint{Date: toTime(r.Date), Count: r.Count} + } + return domain.AnalyticsSubscriptionsSection{ + TotalSubscriptions: summary.Total, + ActiveSubscriptions: summary.Active, + NewToday: summary.NewToday, + NewWeek: summary.NewThisWeek, + NewMonth: summary.NewThisMonth, + ByStatus: statuses, + RevenueByPlan: plans, + NewSubscriptionsLast30Days: timePoints, + } +} + +func mapPaymentsSection( + summary dbgen.AnalyticsPaymentsSummaryRow, + byStatus []dbgen.AnalyticsPaymentsByStatusRow, + byMethod []dbgen.AnalyticsPaymentsByMethodRow, + revenue []dbgen.AnalyticsRevenueLast30DaysRow, +) domain.AnalyticsPaymentsSection { + statuses := make([]domain.AnalyticsLabelAmount, len(byStatus)) + for i, r := range byStatus { + statuses[i] = domain.AnalyticsLabelAmount{Label: r.Status, Count: r.Count, Amount: r.TotalAmount} + } + methods := make([]domain.AnalyticsLabelAmount, len(byMethod)) + for i, r := range byMethod { + methods[i] = domain.AnalyticsLabelAmount{Label: r.PaymentMethod, Count: r.Count, Amount: r.TotalAmount} + } + timePoints := make([]domain.AnalyticsRevenueTimePoint, len(revenue)) + for i, r := range revenue { + timePoints[i] = domain.AnalyticsRevenueTimePoint{Date: toTime(r.Date), Revenue: r.TotalRevenue} + } + return domain.AnalyticsPaymentsSection{ + TotalRevenue: summary.TotalRevenue, + AvgTransactionValue: summary.AvgValue, + TotalPayments: summary.TotalPayments, + SuccessfulPayments: summary.SuccessfulPayments, + ByStatus: statuses, + ByMethod: methods, + RevenueLast30Days: timePoints, + } +} + +func mapContentSection( + counts dbgen.AnalyticsQuestionsCountsRow, + byType []dbgen.AnalyticsQuestionsByTypeRow, + setsByType []dbgen.AnalyticsQuestionSetsByTypeRow, +) domain.AnalyticsContentSection { + qTypes := make([]domain.AnalyticsLabelCount, len(byType)) + for i, r := range byType { + qTypes[i] = domain.AnalyticsLabelCount{Label: r.QuestionType, Count: r.Count} + } + sTypes := make([]domain.AnalyticsLabelCount, len(setsByType)) + for i, r := range setsByType { + sTypes[i] = domain.AnalyticsLabelCount{Label: r.SetType, Count: r.Count} + } + return domain.AnalyticsContentSection{ + TotalQuestions: counts.TotalQuestions, + TotalQuestionSets: counts.TotalQuestionSets, + QuestionsByType: qTypes, + QuestionSetsByType: sTypes, + } +} + +func mapNotificationsSection( + summary dbgen.AnalyticsNotificationsSummaryRow, + byChannel []dbgen.AnalyticsNotificationsByChannelRow, + byType []dbgen.AnalyticsNotificationsByTypeRow, +) domain.AnalyticsNotificationsSection { + channels := make([]domain.AnalyticsLabelCount, len(byChannel)) + for i, r := range byChannel { + channels[i] = domain.AnalyticsLabelCount{Label: r.Channel, Count: r.Count} + } + types := make([]domain.AnalyticsLabelCount, len(byType)) + for i, r := range byType { + types[i] = domain.AnalyticsLabelCount{Label: r.Type, Count: r.Count} + } + return domain.AnalyticsNotificationsSection{ + TotalSent: summary.Total, + ReadCount: summary.Read, + UnreadCount: summary.Unread, + ByChannel: channels, + ByType: types, + } +} + +func mapIssuesSection( + summary dbgen.AnalyticsIssuesSummaryRow, + byStatus []dbgen.AnalyticsIssuesByStatusRow, + byType []dbgen.AnalyticsIssuesByTypeRow, +) domain.AnalyticsIssuesSection { + statuses := make([]domain.AnalyticsLabelCount, len(byStatus)) + for i, r := range byStatus { + statuses[i] = domain.AnalyticsLabelCount{Label: r.Status, Count: r.Count} + } + types := make([]domain.AnalyticsLabelCount, len(byType)) + for i, r := range byType { + types[i] = domain.AnalyticsLabelCount{Label: r.IssueType, Count: r.Count} + } + return domain.AnalyticsIssuesSection{ + TotalIssues: summary.Total, + ResolvedIssues: summary.Resolved, + ResolutionRate: summary.ResolutionRate, + ByStatus: statuses, + ByType: types, + } +} + +func mapTeamSection( + totalMembers int64, + byRole []dbgen.AnalyticsTeamByRoleRow, + byStatus []dbgen.AnalyticsTeamByStatusRow, +) domain.AnalyticsTeamSection { + roles := make([]domain.AnalyticsLabelCount, len(byRole)) + for i, r := range byRole { + roles[i] = domain.AnalyticsLabelCount{Label: r.TeamRole, Count: r.Count} + } + statuses := make([]domain.AnalyticsLabelCount, len(byStatus)) + for i, r := range byStatus { + statuses[i] = domain.AnalyticsLabelCount{Label: r.Status, Count: r.Count} + } + return domain.AnalyticsTeamSection{ + TotalMembers: totalMembers, + ByRole: roles, + ByStatus: statuses, + } +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index bdefc28..e743402 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -3,14 +3,15 @@ package handlers import ( "context" + dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/config" "Yimaru-Backend/internal/domain" activitylogservice "Yimaru-Backend/internal/services/activity_log" "Yimaru-Backend/internal/services/arifpay" - issuereporting "Yimaru-Backend/internal/services/issue_reporting" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" course_management "Yimaru-Backend/internal/services/course_management" + issuereporting "Yimaru-Backend/internal/services/issue_reporting" notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/recommendation" @@ -52,6 +53,7 @@ type Handler struct { validator *customvalidator.CustomValidator Cfg *config.Config mongoLoggerSvc *zap.Logger + analyticsDB *dbgen.Queries } func New( @@ -75,6 +77,7 @@ func New( jwtConfig jwtutil.JwtConfig, cfg *config.Config, mongoLoggerSvc *zap.Logger, + analyticsDB *dbgen.Queries, ) *Handler { return &Handler{ assessmentSvc: assessmentSvc, @@ -97,6 +100,7 @@ func New( jwtConfig: jwtConfig, Cfg: cfg, mongoLoggerSvc: mongoLoggerSvc, + analyticsDB: analyticsDB, } } diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index a1677a9..6ab614d 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -28,23 +28,21 @@ func (h *Handler) ConnectSocket(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") } + // Convert fasthttp request to net/http request for gorilla upgrader + stdReq := &http.Request{ + Method: http.MethodGet, + Header: make(http.Header), + } + c.Context().Request.Header.VisitAll(func(key, value []byte) { + stdReq.Header.Set(string(key), string(value)) + }) + stdReq.Host = string(c.Context().Host()) + stdReq.RequestURI = string(c.Context().RequestURI()) + // Hijack the underlying net.Conn from fasthttp - done := make(chan struct{}) + // The hijack callback runs after the handler returns, so we must not block. c.Context().HijackSetNoResponse(true) c.Context().Hijack(func(netConn net.Conn) { - defer close(done) - - // Build a minimal http.Request for gorilla's upgrader - stdReq := &http.Request{ - Method: http.MethodGet, - Header: make(http.Header), - } - c.Context().Request.Header.VisitAll(func(key, value []byte) { - stdReq.Header.Set(string(key), string(value)) - }) - stdReq.Host = string(c.Context().Host()) - stdReq.RequestURI = string(c.Context().RequestURI()) - // Create a hijackable response writer around the raw connection hjRW := &hijackResponseWriter{ conn: netConn, @@ -99,7 +97,6 @@ func (h *Handler) ConnectSocket(c *fiber.Ctx) error { } }) - <-done return nil } @@ -314,6 +311,7 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { notification := &domain.Notification{ ID: "", RecipientID: req.RecipientID, + ReceiverType: domain.ReceiverTypeFromReciever(req.Reciever), Type: req.Type, Level: req.Level, ErrorSeverity: errorSeverity, @@ -368,6 +366,7 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { notification := &domain.Notification{ ID: "", RecipientID: user.ID, + ReceiverType: domain.ReceiverTypeFromReciever(req.Reciever), Type: req.Type, Level: req.Level, ErrorSeverity: errorSeverity, diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 0df25fc..9e70fd6 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -33,6 +33,7 @@ func (a *App) initAppRoutes() { a.JwtConfig, a.cfg, a.mongoLoggerSvc, + a.analyticsDB, ) a.fiber.Get("/", func(c *fiber.Ctx) error { @@ -359,6 +360,9 @@ func (a *App) initAppRoutes() { groupV1.Get("/settings/:key", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingByKey) groupV1.Put("/settings", a.authMiddleware, a.SuperAdminOnly, h.UpdateGlobalSettingList) + // Analytics Routes + groupV1.Get("/analytics/dashboard", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAnalyticsDashboard) + // Vimeo Video Hosting Routes vimeoGroup := groupV1.Group("/vimeo") vimeoGroup.Get("/videos/:video_id", a.authMiddleware, h.GetVimeoVideo)