analytics service + inapp notification websocket fix

This commit is contained in:
Yared Yemane 2026-02-16 08:36:46 -08:00
parent 7d626d059f
commit aa6194013c
17 changed files with 1823 additions and 52 deletions

View File

@ -5,6 +5,7 @@ import (
// "context" // "context"
_ "Yimaru-Backend/docs" _ "Yimaru-Backend/docs"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
customlogger "Yimaru-Backend/internal/logger" customlogger "Yimaru-Backend/internal/logger"
@ -81,6 +82,7 @@ func main() {
zap.ReplaceGlobals(domain.MongoDBLogger) zap.ReplaceGlobals(domain.MongoDBLogger)
store := repository.NewStore(db) store := repository.NewStore(db)
analyticsDB := dbgen.New(db)
v := customvalidator.NewCustomValidator(validator.New()) v := customvalidator.NewCustomValidator(validator.New())
// Initialize services // Initialize services
@ -426,6 +428,7 @@ func main() {
recommendationSvc, recommendationSvc,
cfg, cfg,
domain.MongoDBLogger, domain.MongoDBLogger,
analyticsDB,
) )
logger.Info("Starting server", "port", cfg.Port) logger.Info("Starting server", "port", cfg.Port)

View File

@ -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;

View File

@ -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'));

276
db/query/analytics.sql Normal file
View File

@ -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;

View File

@ -1,6 +1,7 @@
-- name: CreateNotification :one -- name: CreateNotification :one
INSERT INTO notifications ( INSERT INTO notifications (
user_id, user_id,
receiver_type,
type, type,
level, level,
channel, channel,
@ -15,7 +16,8 @@ VALUES (
$4, $4,
$5, $5,
$6, $6,
$7 $7,
$8
) )
RETURNING *; RETURNING *;

View File

@ -26,7 +26,7 @@ services:
image: dpage/pgadmin4:latest image: dpage/pgadmin4:latest
restart: always restart: always
ports: ports:
- "5050:80" - "5030:80"
environment: environment:
PGADMIN_DEFAULT_EMAIL: admin@local.dev PGADMIN_DEFAULT_EMAIL: admin@local.dev
PGADMIN_DEFAULT_PASSWORD: admin PGADMIN_DEFAULT_PASSWORD: admin

947
gen/db/analytics.sql.go Normal file
View File

@ -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
}

View File

@ -66,17 +66,18 @@ type ModuleToSubCourse struct {
} }
type Notification struct { type Notification struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Type string `json:"type"` Type string `json:"type"`
Level string `json:"level"` Level string `json:"level"`
Channel pgtype.Text `json:"channel"` Channel pgtype.Text `json:"channel"`
Title string `json:"title"` Title string `json:"title"`
Message string `json:"message"` Message string `json:"message"`
Payload []byte `json:"payload"` Payload []byte `json:"payload"`
IsRead bool `json:"is_read"` IsRead bool `json:"is_read"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
ReadAt pgtype.Timestamptz `json:"read_at"` ReadAt pgtype.Timestamptz `json:"read_at"`
ReceiverType string `json:"receiver_type"`
} }
type Otp struct { type Otp struct {

View File

@ -28,6 +28,7 @@ func (q *Queries) CountUnreadNotifications(ctx context.Context, userID int64) (i
const CreateNotification = `-- name: CreateNotification :one const CreateNotification = `-- name: CreateNotification :one
INSERT INTO notifications ( INSERT INTO notifications (
user_id, user_id,
receiver_type,
type, type,
level, level,
channel, channel,
@ -42,24 +43,27 @@ VALUES (
$4, $4,
$5, $5,
$6, $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 { type CreateNotificationParams struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Type string `json:"type"` ReceiverType string `json:"receiver_type"`
Level string `json:"level"` Type string `json:"type"`
Channel pgtype.Text `json:"channel"` Level string `json:"level"`
Title string `json:"title"` Channel pgtype.Text `json:"channel"`
Message string `json:"message"` Title string `json:"title"`
Payload []byte `json:"payload"` Message string `json:"message"`
Payload []byte `json:"payload"`
} }
func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error) { func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error) {
row := q.db.QueryRow(ctx, CreateNotification, row := q.db.QueryRow(ctx, CreateNotification,
arg.UserID, arg.UserID,
arg.ReceiverType,
arg.Type, arg.Type,
arg.Level, arg.Level,
arg.Channel, arg.Channel,
@ -80,6 +84,7 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification
&i.IsRead, &i.IsRead,
&i.CreatedAt, &i.CreatedAt,
&i.ReadAt, &i.ReadAt,
&i.ReceiverType,
) )
return i, err return i, err
} }
@ -95,7 +100,7 @@ func (q *Queries) DeleteUserNotifications(ctx context.Context, userID int64) err
} }
const GetAllNotifications = `-- name: GetAllNotifications :many 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 FROM notifications
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
@ -127,6 +132,7 @@ func (q *Queries) GetAllNotifications(ctx context.Context, arg GetAllNotificatio
&i.IsRead, &i.IsRead,
&i.CreatedAt, &i.CreatedAt,
&i.ReadAt, &i.ReadAt,
&i.ReceiverType,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -139,7 +145,7 @@ func (q *Queries) GetAllNotifications(ctx context.Context, arg GetAllNotificatio
} }
const GetNotification = `-- name: GetNotification :one 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 FROM notifications
WHERE id = $1 WHERE id = $1
LIMIT 1 LIMIT 1
@ -160,6 +166,7 @@ func (q *Queries) GetNotification(ctx context.Context, id int64) (Notification,
&i.IsRead, &i.IsRead,
&i.CreatedAt, &i.CreatedAt,
&i.ReadAt, &i.ReadAt,
&i.ReceiverType,
) )
return i, err return i, err
} }
@ -190,7 +197,7 @@ func (q *Queries) GetUserNotificationCount(ctx context.Context, userID int64) (i
} }
const GetUserNotifications = `-- name: GetUserNotifications :many 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 FROM notifications
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
@ -224,6 +231,7 @@ func (q *Queries) GetUserNotifications(ctx context.Context, arg GetUserNotificat
&i.IsRead, &i.IsRead,
&i.CreatedAt, &i.CreatedAt,
&i.ReadAt, &i.ReadAt,
&i.ReceiverType,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -266,7 +274,7 @@ UPDATE notifications
SET is_read = TRUE, SET is_read = TRUE,
read_at = NOW() read_at = NOW()
WHERE id = $1 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) { 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.IsRead,
&i.CreatedAt, &i.CreatedAt,
&i.ReadAt, &i.ReadAt,
&i.ReceiverType,
) )
return i, err return i, err
} }
@ -293,7 +302,7 @@ UPDATE notifications
SET is_read = FALSE, SET is_read = FALSE,
read_at = NULL read_at = NULL
WHERE id = $1 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) { 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.IsRead,
&i.CreatedAt, &i.CreatedAt,
&i.ReadAt, &i.ReadAt,
&i.ReceiverType,
) )
return i, err return i, err
} }

View File

@ -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"`
}

View File

@ -12,6 +12,12 @@ type NotificationLevel string
type NotificationErrorSeverity string type NotificationErrorSeverity string
type NotificationDeliveryStatus string type NotificationDeliveryStatus string
type DeliveryChannel string type DeliveryChannel string
type ReceiverType string
const (
ReceiverTypeUser ReceiverType = "user"
ReceiverTypeTeamMember ReceiverType = "team_member"
)
const ( const (
NOTIFICATION_TYPE_KNOWLEDGE_LEVEL_UPDATE NotificationType = "knowledge_level_update" NOTIFICATION_TYPE_KNOWLEDGE_LEVEL_UPDATE NotificationType = "knowledge_level_update"
@ -30,6 +36,7 @@ const (
NotificationRecieverSideCustomer NotificationRecieverSide = "customer" NotificationRecieverSideCustomer NotificationRecieverSide = "customer"
NotificationRecieverSideCashier NotificationRecieverSide = "cashier" NotificationRecieverSideCashier NotificationRecieverSide = "cashier"
NotificationRecieverSideBranchManager NotificationRecieverSide = "branch_manager" NotificationRecieverSideBranchManager NotificationRecieverSide = "branch_manager"
NotificationRecieverSideTeamMember NotificationRecieverSide = "team_member"
NotificationDeliverySchemeBulk NotificationDeliveryScheme = "bulk" NotificationDeliverySchemeBulk NotificationDeliveryScheme = "bulk"
NotificationDeliverySchemeSingle NotificationDeliveryScheme = "single" NotificationDeliverySchemeSingle NotificationDeliveryScheme = "single"
@ -52,7 +59,7 @@ const (
DeliveryChannelEmail DeliveryChannel = "email" DeliveryChannelEmail DeliveryChannel = "email"
DeliveryChannelSMS DeliveryChannel = "sms" DeliveryChannelSMS DeliveryChannel = "sms"
DeliveryChannelPush DeliveryChannel = "push" DeliveryChannelPush DeliveryChannel = "push"
DeliveryChannelInApp DeliveryChannel = "in-app" DeliveryChannelInApp DeliveryChannel = "in_app"
) )
type NotificationPayload struct { type NotificationPayload struct {
@ -64,6 +71,7 @@ type NotificationPayload struct {
type Notification struct { type Notification struct {
ID string `json:"id"` ID string `json:"id"`
RecipientID int64 `json:"recipient_id"` RecipientID int64 `json:"recipient_id"`
ReceiverType ReceiverType `json:"receiver_type"`
Type NotificationType `json:"type"` Type NotificationType `json:"type"`
Level NotificationLevel `json:"level"` Level NotificationLevel `json:"level"`
ErrorSeverity NotificationErrorSeverity `json:"error_severity"` ErrorSeverity NotificationErrorSeverity `json:"error_severity"`
@ -81,6 +89,7 @@ type Notification struct {
} }
type CreateNotification struct { type CreateNotification struct {
RecipientID int64 `json:"recipient_id"` RecipientID int64 `json:"recipient_id"`
ReceiverType ReceiverType `json:"receiver_type"`
Type NotificationType `json:"type"` Type NotificationType `json:"type"`
Level NotificationLevel `json:"level"` Level NotificationLevel `json:"level"`
ErrorSeverity *NotificationErrorSeverity `json:"error_severity"` ErrorSeverity *NotificationErrorSeverity `json:"error_severity"`
@ -109,7 +118,6 @@ func FromJSON(data []byte) (*Notification, error) {
} }
func ReceiverFromRole(role Role) NotificationRecieverSide { func ReceiverFromRole(role Role) NotificationRecieverSide {
switch role { switch role {
case RoleAdmin: case RoleAdmin:
return NotificationRecieverSideAdmin return NotificationRecieverSideAdmin
@ -123,3 +131,12 @@ func ReceiverFromRole(role Role) NotificationRecieverSide {
return "" return ""
} }
} }
func ReceiverTypeFromReciever(reciever NotificationRecieverSide) ReceiverType {
switch reciever {
case NotificationRecieverSideTeamMember:
return ReceiverTypeTeamMember
default:
return ReceiverTypeUser
}
}

View File

@ -25,14 +25,20 @@ func (r *Store) CreateNotification(
n *domain.Notification, n *domain.Notification,
) (*domain.Notification, error) { ) (*domain.Notification, error) {
receiverType := string(n.ReceiverType)
if receiverType == "" {
receiverType = string(domain.ReceiverTypeUser)
}
params := dbgen.CreateNotificationParams{ params := dbgen.CreateNotificationParams{
UserID: n.RecipientID, UserID: n.RecipientID,
Type: string(n.Type), ReceiverType: receiverType,
Level: string(n.Level), Type: string(n.Type),
Channel: pgtype.Text{String: string(n.DeliveryChannel)}, Level: string(n.Level),
Title: n.Payload.Headline, Channel: pgtype.Text{String: string(n.DeliveryChannel), Valid: true},
Message: n.Payload.Message, Title: n.Payload.Headline,
Payload: marshalPayload(n.Payload), Message: n.Payload.Message,
Payload: marshalPayload(n.Payload),
} }
dbNotif, err := r.queries.CreateNotification(ctx, params) dbNotif, err := r.queries.CreateNotification(ctx, params)
@ -178,6 +184,7 @@ func mapDBToDomain(db *dbgen.Notification) *domain.Notification {
return &domain.Notification{ return &domain.Notification{
ID: strconv.FormatInt(db.ID, 10), ID: strconv.FormatInt(db.ID, 10),
RecipientID: db.UserID, RecipientID: db.UserID,
ReceiverType: domain.ReceiverType(db.ReceiverType),
Type: domain.NotificationType(db.Type), Type: domain.NotificationType(db.Type),
Level: domain.NotificationLevel(db.Level), Level: domain.NotificationLevel(db.Level),
DeliveryChannel: channel, DeliveryChannel: channel,

View File

@ -1,6 +1,7 @@
package httpserver package httpserver
import ( import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
@ -54,6 +55,7 @@ type App struct {
JwtConfig jwtutil.JwtConfig JwtConfig jwtutil.JwtConfig
Logger *slog.Logger Logger *slog.Logger
mongoLoggerSvc *zap.Logger mongoLoggerSvc *zap.Logger
analyticsDB *dbgen.Queries
} }
func NewApp( func NewApp(
@ -77,6 +79,7 @@ func NewApp(
recommendationSvc recommendation.RecommendationService, recommendationSvc recommendation.RecommendationService,
cfg *config.Config, cfg *config.Config,
mongoLoggerSvc *zap.Logger, mongoLoggerSvc *zap.Logger,
analyticsDB *dbgen.Queries,
) *App { ) *App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
CaseSensitive: true, CaseSensitive: true,
@ -119,6 +122,7 @@ func NewApp(
recommendationSvc: recommendationSvc, recommendationSvc: recommendationSvc,
cfg: cfg, cfg: cfg,
mongoLoggerSvc: mongoLoggerSvc, mongoLoggerSvc: mongoLoggerSvc,
analyticsDB: analyticsDB,
} }
s.initAppRoutes() s.initAppRoutes()

View File

@ -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,
}
}

View File

@ -3,14 +3,15 @@ package handlers
import ( import (
"context" "context"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
course_management "Yimaru-Backend/internal/services/course_management" course_management "Yimaru-Backend/internal/services/course_management"
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
"Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/recommendation"
@ -52,6 +53,7 @@ type Handler struct {
validator *customvalidator.CustomValidator validator *customvalidator.CustomValidator
Cfg *config.Config Cfg *config.Config
mongoLoggerSvc *zap.Logger mongoLoggerSvc *zap.Logger
analyticsDB *dbgen.Queries
} }
func New( func New(
@ -75,6 +77,7 @@ func New(
jwtConfig jwtutil.JwtConfig, jwtConfig jwtutil.JwtConfig,
cfg *config.Config, cfg *config.Config,
mongoLoggerSvc *zap.Logger, mongoLoggerSvc *zap.Logger,
analyticsDB *dbgen.Queries,
) *Handler { ) *Handler {
return &Handler{ return &Handler{
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
@ -97,6 +100,7 @@ func New(
jwtConfig: jwtConfig, jwtConfig: jwtConfig,
Cfg: cfg, Cfg: cfg,
mongoLoggerSvc: mongoLoggerSvc, mongoLoggerSvc: mongoLoggerSvc,
analyticsDB: analyticsDB,
} }
} }

View File

@ -28,23 +28,21 @@ func (h *Handler) ConnectSocket(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") 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 // 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().HijackSetNoResponse(true)
c.Context().Hijack(func(netConn net.Conn) { 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 // Create a hijackable response writer around the raw connection
hjRW := &hijackResponseWriter{ hjRW := &hijackResponseWriter{
conn: netConn, conn: netConn,
@ -99,7 +97,6 @@ func (h *Handler) ConnectSocket(c *fiber.Ctx) error {
} }
}) })
<-done
return nil return nil
} }
@ -314,6 +311,7 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error {
notification := &domain.Notification{ notification := &domain.Notification{
ID: "", ID: "",
RecipientID: req.RecipientID, RecipientID: req.RecipientID,
ReceiverType: domain.ReceiverTypeFromReciever(req.Reciever),
Type: req.Type, Type: req.Type,
Level: req.Level, Level: req.Level,
ErrorSeverity: errorSeverity, ErrorSeverity: errorSeverity,
@ -368,6 +366,7 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error {
notification := &domain.Notification{ notification := &domain.Notification{
ID: "", ID: "",
RecipientID: user.ID, RecipientID: user.ID,
ReceiverType: domain.ReceiverTypeFromReciever(req.Reciever),
Type: req.Type, Type: req.Type,
Level: req.Level, Level: req.Level,
ErrorSeverity: errorSeverity, ErrorSeverity: errorSeverity,

View File

@ -33,6 +33,7 @@ func (a *App) initAppRoutes() {
a.JwtConfig, a.JwtConfig,
a.cfg, a.cfg,
a.mongoLoggerSvc, a.mongoLoggerSvc,
a.analyticsDB,
) )
a.fiber.Get("/", func(c *fiber.Ctx) error { 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.Get("/settings/:key", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingByKey)
groupV1.Put("/settings", a.authMiddleware, a.SuperAdminOnly, h.UpdateGlobalSettingList) 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 // Vimeo Video Hosting Routes
vimeoGroup := groupV1.Group("/vimeo") vimeoGroup := groupV1.Group("/vimeo")
vimeoGroup.Get("/videos/:video_id", a.authMiddleware, h.GetVimeoVideo) vimeoGroup.Get("/videos/:video_id", a.authMiddleware, h.GetVimeoVideo)