Compare commits

...

4 Commits

Author SHA1 Message Date
f824c16c64 user list response fix 2026-05-18 00:09:26 -07:00
2883561525 Add monthly revenue trend for analytics when year is specified.
Exposes payments.revenue_monthly with Jan–Dec SUCCESS totals (UTC) per currency for dashboard charts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 23:32:36 -07:00
a1696bf1e0 Fix analytics dashboard course counts for LMS and exam_prep hierarchies.
Replace stub AnalyticsCourseCounts query and expose lms / exam_prep inventory in the courses section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 22:34:25 -07:00
7f8ef3373c Add paginated Vimeo video list API (GET /me/videos).
Exposes the Vimeo account library for admin workflows and syncs swagger docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 22:23:50 -07:00
20 changed files with 2603 additions and 58 deletions

View File

@ -206,16 +206,66 @@ LEFT JOIN payments p ON COALESCE(p.paid_at, p.created_at)::date = d.date
GROUP BY d.date GROUP BY d.date
ORDER BY d.date; ORDER BY d.date;
-- Monthly successful revenue for a calendar year (UTC buckets). Months with multiple currencies emit one row each; months with no revenue emit one row (currency ETB, revenue 0).
-- name: AnalyticsRevenueMonthlyByYear :many
WITH months AS (
SELECT bucket
FROM generate_series(
make_timestamptz(sqlc.arg('report_year')::int, 1, 1, 0, 0, 0, 'UTC'),
make_timestamptz(sqlc.arg('report_year')::int, 12, 1, 0, 0, 0, 'UTC'),
INTERVAL '1 month'
) AS gs(bucket)
),
by_month_currency AS (
SELECT
date_trunc('month', COALESCE(p.paid_at, p.created_at) AT TIME ZONE 'UTC') AS ym,
p.currency,
SUM(p.amount)::float8 AS total_revenue
FROM payments p
WHERE p.status = 'SUCCESS'
AND EXTRACT(YEAR FROM COALESCE(p.paid_at, p.created_at) AT TIME ZONE 'UTC')::int = sqlc.arg('report_year')::int
GROUP BY 1, 2
)
SELECT
(EXTRACT(MONTH FROM m.bucket AT TIME ZONE 'UTC'))::int AS month,
date_trunc('month', m.bucket AT TIME ZONE 'UTC')::date AS month_start,
COALESCE(b.currency, 'ETB'::varchar) AS currency,
COALESCE(b.total_revenue, 0)::float8 AS total_revenue
FROM months m
LEFT JOIN by_month_currency b ON b.ym = date_trunc('month', m.bucket AT TIME ZONE 'UTC')
ORDER BY m.bucket, COALESCE(b.currency, ''::varchar);
-- ===================== -- =====================
-- Course Analytics -- Course Analytics
-- ===================== -- =====================
-- LMS: programs -> courses -> modules -> lessons; lms_practices attach to course, module, or lesson.
-- Exam prep: exam_prep.catalog_courses -> units -> unit_modules -> unit_module_lessons; exam_prep.lesson_practices.
-- Legacy dashboard fields map to: programs, courses, modules, and video-bearing lessons (LMS + exam prep).
-- name: AnalyticsCourseCounts :one -- name: AnalyticsCourseCounts :one
SELECT SELECT
0::bigint AS total_categories, (SELECT COUNT(*)::bigint FROM programs) AS total_categories,
0::bigint AS total_courses, (SELECT COUNT(*)::bigint FROM courses) AS total_courses,
0::bigint AS total_sub_courses, (SELECT COUNT(*)::bigint FROM modules) AS total_sub_courses,
0::bigint AS total_videos; (
COALESCE((SELECT COUNT(*)::bigint FROM lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL), 0::bigint)
+ COALESCE((SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL), 0::bigint)
)::bigint AS total_videos,
(SELECT COUNT(*)::bigint FROM programs) AS lms_programs,
(SELECT COUNT(*)::bigint FROM courses) AS lms_courses,
(SELECT COUNT(*)::bigint FROM modules) AS lms_modules,
(SELECT COUNT(*)::bigint FROM lessons) AS lms_lessons,
(SELECT COUNT(*)::bigint FROM lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS lms_lessons_with_video,
(SELECT COUNT(*)::bigint FROM lms_practices) AS lms_practices,
(SELECT COUNT(*)::bigint FROM lms_practices WHERE course_id IS NOT NULL) AS lms_practices_at_course,
(SELECT COUNT(*)::bigint FROM lms_practices WHERE module_id IS NOT NULL) AS lms_practices_at_module,
(SELECT COUNT(*)::bigint FROM lms_practices WHERE lesson_id IS NOT NULL) AS lms_practices_at_lesson,
(SELECT COUNT(*)::bigint FROM exam_prep.catalog_courses) AS exam_prep_catalog_courses,
(SELECT COUNT(*)::bigint FROM exam_prep.units) AS exam_prep_units,
(SELECT COUNT(*)::bigint FROM exam_prep.unit_modules) AS exam_prep_unit_modules,
(SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons) AS exam_prep_lessons,
(SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS exam_prep_lessons_with_video,
(SELECT COUNT(*)::bigint FROM exam_prep.lesson_practices) AS exam_prep_lesson_practices;
-- ===================== -- =====================
-- Content Analytics -- Content Analytics

View File

@ -61,6 +61,29 @@ FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.id = $1; WHERE us.id = $1;
-- name: ListActiveSubscriptionsByUserIDs :many
-- One ACTIVE, non-expired row per user (latest expires_at wins), same rules as GetActiveSubscriptionByUserID.
SELECT DISTINCT ON (us.user_id)
us.user_id,
us.id,
us.plan_id,
us.starts_at,
us.expires_at,
us.status,
us.auto_renew,
us.payment_method,
sp.name AS plan_name,
sp.duration_value,
sp.duration_unit,
sp.price,
sp.currency
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.user_id = ANY($1::bigint[])
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
ORDER BY us.user_id, us.expires_at DESC;
-- name: GetActiveSubscriptionByUserID :one -- name: GetActiveSubscriptionByUserID :one
SELECT SELECT
us.*, us.*,

View File

@ -702,6 +702,64 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/analytics/dashboard": {
"get": {
"description": "Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. When year is set, payments.revenue_monthly returns JanDec SUCCESS revenue totals (UTC) per currency for that calendar year — use for yearly revenue charts. Daily series remains in revenue_last_30_days (see date_filter.series_*). Courses section counts LMS + exam_prep inventory.",
"produces": [
"application/json"
],
"tags": [
"analytics"
],
"summary": "Analytics dashboard",
"parameters": [
{
"type": "integer",
"description": "Calendar year (e.g. 2025)",
"name": "year",
"in": "query"
},
{
"type": "integer",
"description": "Calendar month 1-12 (requires year)",
"name": "month",
"in": "query"
},
{
"type": "string",
"description": "Custom range start (YYYY-MM-DD or RFC3339)",
"name": "from",
"in": "query"
},
{
"type": "string",
"description": "Custom range end (YYYY-MM-DD or RFC3339, inclusive)",
"name": "to",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.AnalyticsDashboard"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/assessment/questions": { "/api/v1/assessment/questions": {
"get": { "get": {
"description": "Returns all active assessment questions from the initial assessment set", "description": "Returns all active assessment questions from the initial assessment set",
@ -8378,7 +8436,7 @@ const docTemplate = `{
}, },
"/api/v1/users": { "/api/v1/users": {
"get": { "get": {
"description": "Get users with optional filters", "description": "Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -8701,6 +8759,105 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/vimeo/videos": {
"get": {
"description": "Returns a paginated list of videos for the Vimeo API token (GET https://api.vimeo.com/me/videos)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Vimeo"
],
"summary": "List videos stored in the Vimeo account",
"parameters": [
{
"type": "integer",
"default": 1,
"description": "Page number (starts at 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 25,
"description": "Page size (Vimeo max 100)",
"name": "per_page",
"in": "query"
},
{
"type": "string",
"description": "Search query",
"name": "query",
"in": "query"
},
{
"type": "string",
"description": "Sort field (e.g. date, alphabetical, plays, likes, comments, duration, relevance)",
"name": "sort",
"in": "query"
},
{
"type": "string",
"description": "asc or desc",
"name": "direction",
"in": "query"
},
{
"type": "string",
"description": "Vimeo filter (e.g. embeddable, playable)",
"name": "filter",
"in": "query"
},
{
"type": "string",
"description": "Vimeo filter_type when using filter",
"name": "filter_type",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.VimeoVideoResponse"
}
},
"metadata": {
"$ref": "#/definitions/handlers.VimeoVideosListMetadata"
}
}
}
]
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/vimeo/videos/{video_id}": { "/api/v1/vimeo/videos/{video_id}": {
"get": { "get": {
"description": "Retrieves video details from Vimeo by video ID", "description": "Retrieves video details from Vimeo by video ID",
@ -9455,6 +9612,472 @@ const docTemplate = `{
"Age55Plus" "Age55Plus"
] ]
}, },
"domain.AnalyticsContentSection": {
"type": "object",
"properties": {
"question_sets_by_type": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"questions_by_type": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"total_question_sets": {
"type": "integer"
},
"total_questions": {
"type": "integer"
}
}
},
"domain.AnalyticsCoursesSection": {
"type": "object",
"properties": {
"exam_prep": {
"$ref": "#/definitions/domain.AnalyticsExamPrepContentCounts"
},
"lms": {
"$ref": "#/definitions/domain.AnalyticsLMSContentCounts"
},
"total_categories": {
"description": "Top-level keys preserved for existing clients: map to LMS programs, courses, modules, and all video lessons (LMS + exam prep).",
"type": "integer"
},
"total_courses": {
"type": "integer"
},
"total_sub_courses": {
"type": "integer"
},
"total_videos": {
"type": "integer"
}
}
},
"domain.AnalyticsDashboard": {
"type": "object",
"properties": {
"content": {
"$ref": "#/definitions/domain.AnalyticsContentSection"
},
"courses": {
"$ref": "#/definitions/domain.AnalyticsCoursesSection"
},
"date_filter": {
"$ref": "#/definitions/domain.AnalyticsDateFilter"
},
"generated_at": {
"type": "string"
},
"issues": {
"$ref": "#/definitions/domain.AnalyticsIssuesSection"
},
"notifications": {
"$ref": "#/definitions/domain.AnalyticsNotificationsSection"
},
"payments": {
"$ref": "#/definitions/domain.AnalyticsPaymentsSection"
},
"subscriptions": {
"$ref": "#/definitions/domain.AnalyticsSubscriptionsSection"
},
"team": {
"$ref": "#/definitions/domain.AnalyticsTeamSection"
},
"users": {
"$ref": "#/definitions/domain.AnalyticsUsersSection"
}
}
},
"domain.AnalyticsDateFilter": {
"type": "object",
"properties": {
"from": {
"type": "string"
},
"mode": {
"type": "string"
},
"month": {
"type": "integer"
},
"range_end": {
"type": "string"
},
"range_start": {
"type": "string"
},
"ref_date": {
"type": "string"
},
"series_end": {
"type": "string"
},
"series_start": {
"type": "string"
},
"to": {
"type": "string"
},
"year": {
"type": "integer"
}
}
},
"domain.AnalyticsExamPrepContentCounts": {
"type": "object",
"properties": {
"catalog_courses": {
"type": "integer"
},
"lesson_practices": {
"type": "integer"
},
"lessons": {
"type": "integer"
},
"lessons_with_video": {
"type": "integer"
},
"unit_modules": {
"type": "integer"
},
"units": {
"type": "integer"
}
}
},
"domain.AnalyticsIssuesSection": {
"type": "object",
"properties": {
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_type": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"resolution_rate": {
"type": "number"
},
"resolved_issues": {
"type": "integer"
},
"total_issues": {
"type": "integer"
}
}
},
"domain.AnalyticsLMSContentCounts": {
"type": "object",
"properties": {
"courses": {
"type": "integer"
},
"lessons": {
"type": "integer"
},
"lessons_with_video": {
"type": "integer"
},
"modules": {
"type": "integer"
},
"practices": {
"type": "integer"
},
"practices_at_course": {
"type": "integer"
},
"practices_at_lesson": {
"type": "integer"
},
"practices_at_module": {
"type": "integer"
},
"programs": {
"type": "integer"
}
}
},
"domain.AnalyticsLabelAmount": {
"type": "object",
"properties": {
"amount": {
"type": "number"
},
"count": {
"type": "integer"
},
"label": {
"type": "string"
}
}
},
"domain.AnalyticsLabelCount": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"label": {
"type": "string"
}
}
},
"domain.AnalyticsMonthlyRevenuePoint": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"label": {
"description": "Short English month label, e.g. Jan",
"type": "string"
},
"month": {
"description": "112",
"type": "integer"
},
"month_start": {
"description": "UTC date of month start (for sorting / tooltips)",
"type": "string"
},
"revenue": {
"description": "SUCCESS payments aggregate for that bucket",
"type": "number"
}
}
},
"domain.AnalyticsNotificationsSection": {
"type": "object",
"properties": {
"by_channel": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_type": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"read_count": {
"type": "integer"
},
"total_sent": {
"type": "integer"
},
"unread_count": {
"type": "integer"
}
}
},
"domain.AnalyticsPaymentsSection": {
"type": "object",
"properties": {
"avg_transaction_value": {
"type": "number"
},
"by_method": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelAmount"
}
},
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelAmount"
}
},
"monthly_revenue_year": {
"description": "MonthlyRevenueYear is set when RevenueMonthly is non-empty (the calendar year of those buckets).",
"type": "integer"
},
"revenue_last_30_days": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsRevenueTimePoint"
}
},
"revenue_monthly": {
"description": "RevenueMonthly is populated only when the request includes year=..., with 12 months (possibly multiple currencies per month).",
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsMonthlyRevenuePoint"
}
},
"successful_payments": {
"type": "integer"
},
"total_payments": {
"type": "integer"
},
"total_revenue": {
"type": "number"
}
}
},
"domain.AnalyticsRevenueByPlan": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"plan_name": {
"type": "string"
},
"revenue": {
"type": "number"
}
}
},
"domain.AnalyticsRevenueTimePoint": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"revenue": {
"type": "number"
}
}
},
"domain.AnalyticsSubscriptionsSection": {
"type": "object",
"properties": {
"active_subscriptions": {
"type": "integer"
},
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"new_month": {
"type": "integer"
},
"new_subscriptions_last_30_days": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsTimePoint"
}
},
"new_today": {
"type": "integer"
},
"new_week": {
"type": "integer"
},
"revenue_by_plan": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsRevenueByPlan"
}
},
"total_subscriptions": {
"type": "integer"
}
}
},
"domain.AnalyticsTeamSection": {
"type": "object",
"properties": {
"by_role": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"total_members": {
"type": "integer"
}
}
},
"domain.AnalyticsTimePoint": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"date": {
"type": "string"
}
}
},
"domain.AnalyticsUsersSection": {
"type": "object",
"properties": {
"by_age_group": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_knowledge_level": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_region": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_role": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"new_month": {
"type": "integer"
},
"new_today": {
"type": "integer"
},
"new_week": {
"type": "integer"
},
"registrations_last_30_days": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsTimePoint"
}
},
"total_users": {
"type": "integer"
}
}
},
"domain.CreateCourseInput": { "domain.CreateCourseInput": {
"type": "object", "type": "object",
"required": [ "required": [
@ -10671,6 +11294,9 @@ const docTemplate = `{
"domain.UserProfileResponse": { "domain.UserProfileResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"active_subscription": {
"$ref": "#/definitions/domain.UserSubscriptionSummary"
},
"age_group": { "age_group": {
"type": "string" "type": "string"
}, },
@ -10778,6 +11404,47 @@ const docTemplate = `{
"UserStatusDeactivated" "UserStatusDeactivated"
] ]
}, },
"domain.UserSubscriptionSummary": {
"type": "object",
"properties": {
"auto_renew": {
"type": "boolean"
},
"currency": {
"type": "string"
},
"duration_unit": {
"type": "string"
},
"duration_value": {
"type": "integer"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "integer"
},
"payment_method": {
"type": "string"
},
"plan_id": {
"type": "integer"
},
"plan_name": {
"type": "string"
},
"price": {
"type": "number"
},
"starts_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"domain.UserSummary": { "domain.UserSummary": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -11198,6 +11865,35 @@ const docTemplate = `{
} }
} }
}, },
"handlers.VimeoVideosListMetadata": {
"type": "object",
"properties": {
"current_page": {
"type": "integer"
},
"first": {
"type": "string"
},
"last": {
"type": "string"
},
"limit": {
"type": "integer"
},
"next": {
"type": "string"
},
"previous": {
"type": "string"
},
"total": {
"type": "integer"
},
"total_pages": {
"type": "integer"
}
}
},
"handlers.addQuestionToSetReq": { "handlers.addQuestionToSetReq": {
"type": "object", "type": "object",
"required": [ "required": [

View File

@ -694,6 +694,64 @@
} }
} }
}, },
"/api/v1/analytics/dashboard": {
"get": {
"description": "Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. When year is set, payments.revenue_monthly returns JanDec SUCCESS revenue totals (UTC) per currency for that calendar year — use for yearly revenue charts. Daily series remains in revenue_last_30_days (see date_filter.series_*). Courses section counts LMS + exam_prep inventory.",
"produces": [
"application/json"
],
"tags": [
"analytics"
],
"summary": "Analytics dashboard",
"parameters": [
{
"type": "integer",
"description": "Calendar year (e.g. 2025)",
"name": "year",
"in": "query"
},
{
"type": "integer",
"description": "Calendar month 1-12 (requires year)",
"name": "month",
"in": "query"
},
{
"type": "string",
"description": "Custom range start (YYYY-MM-DD or RFC3339)",
"name": "from",
"in": "query"
},
{
"type": "string",
"description": "Custom range end (YYYY-MM-DD or RFC3339, inclusive)",
"name": "to",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.AnalyticsDashboard"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/assessment/questions": { "/api/v1/assessment/questions": {
"get": { "get": {
"description": "Returns all active assessment questions from the initial assessment set", "description": "Returns all active assessment questions from the initial assessment set",
@ -8370,7 +8428,7 @@
}, },
"/api/v1/users": { "/api/v1/users": {
"get": { "get": {
"description": "Get users with optional filters", "description": "Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -8693,6 +8751,105 @@
} }
} }
}, },
"/api/v1/vimeo/videos": {
"get": {
"description": "Returns a paginated list of videos for the Vimeo API token (GET https://api.vimeo.com/me/videos)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Vimeo"
],
"summary": "List videos stored in the Vimeo account",
"parameters": [
{
"type": "integer",
"default": 1,
"description": "Page number (starts at 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 25,
"description": "Page size (Vimeo max 100)",
"name": "per_page",
"in": "query"
},
{
"type": "string",
"description": "Search query",
"name": "query",
"in": "query"
},
{
"type": "string",
"description": "Sort field (e.g. date, alphabetical, plays, likes, comments, duration, relevance)",
"name": "sort",
"in": "query"
},
{
"type": "string",
"description": "asc or desc",
"name": "direction",
"in": "query"
},
{
"type": "string",
"description": "Vimeo filter (e.g. embeddable, playable)",
"name": "filter",
"in": "query"
},
{
"type": "string",
"description": "Vimeo filter_type when using filter",
"name": "filter_type",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.VimeoVideoResponse"
}
},
"metadata": {
"$ref": "#/definitions/handlers.VimeoVideosListMetadata"
}
}
}
]
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/vimeo/videos/{video_id}": { "/api/v1/vimeo/videos/{video_id}": {
"get": { "get": {
"description": "Retrieves video details from Vimeo by video ID", "description": "Retrieves video details from Vimeo by video ID",
@ -9447,6 +9604,472 @@
"Age55Plus" "Age55Plus"
] ]
}, },
"domain.AnalyticsContentSection": {
"type": "object",
"properties": {
"question_sets_by_type": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"questions_by_type": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"total_question_sets": {
"type": "integer"
},
"total_questions": {
"type": "integer"
}
}
},
"domain.AnalyticsCoursesSection": {
"type": "object",
"properties": {
"exam_prep": {
"$ref": "#/definitions/domain.AnalyticsExamPrepContentCounts"
},
"lms": {
"$ref": "#/definitions/domain.AnalyticsLMSContentCounts"
},
"total_categories": {
"description": "Top-level keys preserved for existing clients: map to LMS programs, courses, modules, and all video lessons (LMS + exam prep).",
"type": "integer"
},
"total_courses": {
"type": "integer"
},
"total_sub_courses": {
"type": "integer"
},
"total_videos": {
"type": "integer"
}
}
},
"domain.AnalyticsDashboard": {
"type": "object",
"properties": {
"content": {
"$ref": "#/definitions/domain.AnalyticsContentSection"
},
"courses": {
"$ref": "#/definitions/domain.AnalyticsCoursesSection"
},
"date_filter": {
"$ref": "#/definitions/domain.AnalyticsDateFilter"
},
"generated_at": {
"type": "string"
},
"issues": {
"$ref": "#/definitions/domain.AnalyticsIssuesSection"
},
"notifications": {
"$ref": "#/definitions/domain.AnalyticsNotificationsSection"
},
"payments": {
"$ref": "#/definitions/domain.AnalyticsPaymentsSection"
},
"subscriptions": {
"$ref": "#/definitions/domain.AnalyticsSubscriptionsSection"
},
"team": {
"$ref": "#/definitions/domain.AnalyticsTeamSection"
},
"users": {
"$ref": "#/definitions/domain.AnalyticsUsersSection"
}
}
},
"domain.AnalyticsDateFilter": {
"type": "object",
"properties": {
"from": {
"type": "string"
},
"mode": {
"type": "string"
},
"month": {
"type": "integer"
},
"range_end": {
"type": "string"
},
"range_start": {
"type": "string"
},
"ref_date": {
"type": "string"
},
"series_end": {
"type": "string"
},
"series_start": {
"type": "string"
},
"to": {
"type": "string"
},
"year": {
"type": "integer"
}
}
},
"domain.AnalyticsExamPrepContentCounts": {
"type": "object",
"properties": {
"catalog_courses": {
"type": "integer"
},
"lesson_practices": {
"type": "integer"
},
"lessons": {
"type": "integer"
},
"lessons_with_video": {
"type": "integer"
},
"unit_modules": {
"type": "integer"
},
"units": {
"type": "integer"
}
}
},
"domain.AnalyticsIssuesSection": {
"type": "object",
"properties": {
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_type": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"resolution_rate": {
"type": "number"
},
"resolved_issues": {
"type": "integer"
},
"total_issues": {
"type": "integer"
}
}
},
"domain.AnalyticsLMSContentCounts": {
"type": "object",
"properties": {
"courses": {
"type": "integer"
},
"lessons": {
"type": "integer"
},
"lessons_with_video": {
"type": "integer"
},
"modules": {
"type": "integer"
},
"practices": {
"type": "integer"
},
"practices_at_course": {
"type": "integer"
},
"practices_at_lesson": {
"type": "integer"
},
"practices_at_module": {
"type": "integer"
},
"programs": {
"type": "integer"
}
}
},
"domain.AnalyticsLabelAmount": {
"type": "object",
"properties": {
"amount": {
"type": "number"
},
"count": {
"type": "integer"
},
"label": {
"type": "string"
}
}
},
"domain.AnalyticsLabelCount": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"label": {
"type": "string"
}
}
},
"domain.AnalyticsMonthlyRevenuePoint": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"label": {
"description": "Short English month label, e.g. Jan",
"type": "string"
},
"month": {
"description": "112",
"type": "integer"
},
"month_start": {
"description": "UTC date of month start (for sorting / tooltips)",
"type": "string"
},
"revenue": {
"description": "SUCCESS payments aggregate for that bucket",
"type": "number"
}
}
},
"domain.AnalyticsNotificationsSection": {
"type": "object",
"properties": {
"by_channel": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_type": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"read_count": {
"type": "integer"
},
"total_sent": {
"type": "integer"
},
"unread_count": {
"type": "integer"
}
}
},
"domain.AnalyticsPaymentsSection": {
"type": "object",
"properties": {
"avg_transaction_value": {
"type": "number"
},
"by_method": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelAmount"
}
},
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelAmount"
}
},
"monthly_revenue_year": {
"description": "MonthlyRevenueYear is set when RevenueMonthly is non-empty (the calendar year of those buckets).",
"type": "integer"
},
"revenue_last_30_days": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsRevenueTimePoint"
}
},
"revenue_monthly": {
"description": "RevenueMonthly is populated only when the request includes year=..., with 12 months (possibly multiple currencies per month).",
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsMonthlyRevenuePoint"
}
},
"successful_payments": {
"type": "integer"
},
"total_payments": {
"type": "integer"
},
"total_revenue": {
"type": "number"
}
}
},
"domain.AnalyticsRevenueByPlan": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"plan_name": {
"type": "string"
},
"revenue": {
"type": "number"
}
}
},
"domain.AnalyticsRevenueTimePoint": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"revenue": {
"type": "number"
}
}
},
"domain.AnalyticsSubscriptionsSection": {
"type": "object",
"properties": {
"active_subscriptions": {
"type": "integer"
},
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"new_month": {
"type": "integer"
},
"new_subscriptions_last_30_days": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsTimePoint"
}
},
"new_today": {
"type": "integer"
},
"new_week": {
"type": "integer"
},
"revenue_by_plan": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsRevenueByPlan"
}
},
"total_subscriptions": {
"type": "integer"
}
}
},
"domain.AnalyticsTeamSection": {
"type": "object",
"properties": {
"by_role": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"total_members": {
"type": "integer"
}
}
},
"domain.AnalyticsTimePoint": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"date": {
"type": "string"
}
}
},
"domain.AnalyticsUsersSection": {
"type": "object",
"properties": {
"by_age_group": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_knowledge_level": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_region": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_role": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"by_status": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsLabelCount"
}
},
"new_month": {
"type": "integer"
},
"new_today": {
"type": "integer"
},
"new_week": {
"type": "integer"
},
"registrations_last_30_days": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.AnalyticsTimePoint"
}
},
"total_users": {
"type": "integer"
}
}
},
"domain.CreateCourseInput": { "domain.CreateCourseInput": {
"type": "object", "type": "object",
"required": [ "required": [
@ -10663,6 +11286,9 @@
"domain.UserProfileResponse": { "domain.UserProfileResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"active_subscription": {
"$ref": "#/definitions/domain.UserSubscriptionSummary"
},
"age_group": { "age_group": {
"type": "string" "type": "string"
}, },
@ -10770,6 +11396,47 @@
"UserStatusDeactivated" "UserStatusDeactivated"
] ]
}, },
"domain.UserSubscriptionSummary": {
"type": "object",
"properties": {
"auto_renew": {
"type": "boolean"
},
"currency": {
"type": "string"
},
"duration_unit": {
"type": "string"
},
"duration_value": {
"type": "integer"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "integer"
},
"payment_method": {
"type": "string"
},
"plan_id": {
"type": "integer"
},
"plan_name": {
"type": "string"
},
"price": {
"type": "number"
},
"starts_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"domain.UserSummary": { "domain.UserSummary": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -11190,6 +11857,35 @@
} }
} }
}, },
"handlers.VimeoVideosListMetadata": {
"type": "object",
"properties": {
"current_page": {
"type": "integer"
},
"first": {
"type": "string"
},
"last": {
"type": "string"
},
"limit": {
"type": "integer"
},
"next": {
"type": "string"
},
"previous": {
"type": "string"
},
"total": {
"type": "integer"
},
"total_pages": {
"type": "integer"
}
}
},
"handlers.addQuestionToSetReq": { "handlers.addQuestionToSetReq": {
"type": "object", "type": "object",
"required": [ "required": [

View File

@ -17,6 +17,316 @@ definitions:
- Age35To44 - Age35To44
- Age45To54 - Age45To54
- Age55Plus - Age55Plus
domain.AnalyticsContentSection:
properties:
question_sets_by_type:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
questions_by_type:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
total_question_sets:
type: integer
total_questions:
type: integer
type: object
domain.AnalyticsCoursesSection:
properties:
exam_prep:
$ref: '#/definitions/domain.AnalyticsExamPrepContentCounts'
lms:
$ref: '#/definitions/domain.AnalyticsLMSContentCounts'
total_categories:
description: 'Top-level keys preserved for existing clients: map to LMS programs,
courses, modules, and all video lessons (LMS + exam prep).'
type: integer
total_courses:
type: integer
total_sub_courses:
type: integer
total_videos:
type: integer
type: object
domain.AnalyticsDashboard:
properties:
content:
$ref: '#/definitions/domain.AnalyticsContentSection'
courses:
$ref: '#/definitions/domain.AnalyticsCoursesSection'
date_filter:
$ref: '#/definitions/domain.AnalyticsDateFilter'
generated_at:
type: string
issues:
$ref: '#/definitions/domain.AnalyticsIssuesSection'
notifications:
$ref: '#/definitions/domain.AnalyticsNotificationsSection'
payments:
$ref: '#/definitions/domain.AnalyticsPaymentsSection'
subscriptions:
$ref: '#/definitions/domain.AnalyticsSubscriptionsSection'
team:
$ref: '#/definitions/domain.AnalyticsTeamSection'
users:
$ref: '#/definitions/domain.AnalyticsUsersSection'
type: object
domain.AnalyticsDateFilter:
properties:
from:
type: string
mode:
type: string
month:
type: integer
range_end:
type: string
range_start:
type: string
ref_date:
type: string
series_end:
type: string
series_start:
type: string
to:
type: string
year:
type: integer
type: object
domain.AnalyticsExamPrepContentCounts:
properties:
catalog_courses:
type: integer
lesson_practices:
type: integer
lessons:
type: integer
lessons_with_video:
type: integer
unit_modules:
type: integer
units:
type: integer
type: object
domain.AnalyticsIssuesSection:
properties:
by_status:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
by_type:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
resolution_rate:
type: number
resolved_issues:
type: integer
total_issues:
type: integer
type: object
domain.AnalyticsLMSContentCounts:
properties:
courses:
type: integer
lessons:
type: integer
lessons_with_video:
type: integer
modules:
type: integer
practices:
type: integer
practices_at_course:
type: integer
practices_at_lesson:
type: integer
practices_at_module:
type: integer
programs:
type: integer
type: object
domain.AnalyticsLabelAmount:
properties:
amount:
type: number
count:
type: integer
label:
type: string
type: object
domain.AnalyticsLabelCount:
properties:
count:
type: integer
label:
type: string
type: object
domain.AnalyticsMonthlyRevenuePoint:
properties:
currency:
type: string
label:
description: Short English month label, e.g. Jan
type: string
month:
description: 112
type: integer
month_start:
description: UTC date of month start (for sorting / tooltips)
type: string
revenue:
description: SUCCESS payments aggregate for that bucket
type: number
type: object
domain.AnalyticsNotificationsSection:
properties:
by_channel:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
by_type:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
read_count:
type: integer
total_sent:
type: integer
unread_count:
type: integer
type: object
domain.AnalyticsPaymentsSection:
properties:
avg_transaction_value:
type: number
by_method:
items:
$ref: '#/definitions/domain.AnalyticsLabelAmount'
type: array
by_status:
items:
$ref: '#/definitions/domain.AnalyticsLabelAmount'
type: array
monthly_revenue_year:
description: MonthlyRevenueYear is set when RevenueMonthly is non-empty (the
calendar year of those buckets).
type: integer
revenue_last_30_days:
items:
$ref: '#/definitions/domain.AnalyticsRevenueTimePoint'
type: array
revenue_monthly:
description: RevenueMonthly is populated only when the request includes year=...,
with 12 months (possibly multiple currencies per month).
items:
$ref: '#/definitions/domain.AnalyticsMonthlyRevenuePoint'
type: array
successful_payments:
type: integer
total_payments:
type: integer
total_revenue:
type: number
type: object
domain.AnalyticsRevenueByPlan:
properties:
currency:
type: string
plan_name:
type: string
revenue:
type: number
type: object
domain.AnalyticsRevenueTimePoint:
properties:
date:
type: string
revenue:
type: number
type: object
domain.AnalyticsSubscriptionsSection:
properties:
active_subscriptions:
type: integer
by_status:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
new_month:
type: integer
new_subscriptions_last_30_days:
items:
$ref: '#/definitions/domain.AnalyticsTimePoint'
type: array
new_today:
type: integer
new_week:
type: integer
revenue_by_plan:
items:
$ref: '#/definitions/domain.AnalyticsRevenueByPlan'
type: array
total_subscriptions:
type: integer
type: object
domain.AnalyticsTeamSection:
properties:
by_role:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
by_status:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
total_members:
type: integer
type: object
domain.AnalyticsTimePoint:
properties:
count:
type: integer
date:
type: string
type: object
domain.AnalyticsUsersSection:
properties:
by_age_group:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
by_knowledge_level:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
by_region:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
by_role:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
by_status:
items:
$ref: '#/definitions/domain.AnalyticsLabelCount'
type: array
new_month:
type: integer
new_today:
type: integer
new_week:
type: integer
registrations_last_30_days:
items:
$ref: '#/definitions/domain.AnalyticsTimePoint'
type: array
total_users:
type: integer
type: object
domain.CreateCourseInput: domain.CreateCourseInput:
properties: properties:
description: description:
@ -835,6 +1145,8 @@ definitions:
type: object type: object
domain.UserProfileResponse: domain.UserProfileResponse:
properties: properties:
active_subscription:
$ref: '#/definitions/domain.UserSubscriptionSummary'
age_group: age_group:
type: string type: string
birth_day: birth_day:
@ -909,6 +1221,33 @@ definitions:
- UserStatusActive - UserStatusActive
- UserStatusSuspended - UserStatusSuspended
- UserStatusDeactivated - UserStatusDeactivated
domain.UserSubscriptionSummary:
properties:
auto_renew:
type: boolean
currency:
type: string
duration_unit:
type: string
duration_value:
type: integer
expires_at:
type: string
id:
type: integer
payment_method:
type: string
plan_id:
type: integer
plan_name:
type: string
price:
type: number
starts_at:
type: string
status:
type: string
type: object
domain.UserSummary: domain.UserSummary:
properties: properties:
active_users: active_users:
@ -1190,6 +1529,25 @@ definitions:
width: width:
type: integer type: integer
type: object type: object
handlers.VimeoVideosListMetadata:
properties:
current_page:
type: integer
first:
type: string
last:
type: string
limit:
type: integer
next:
type: string
previous:
type: string
total:
type: integer
total_pages:
type: integer
type: object
handlers.addQuestionToSetReq: handlers.addQuestionToSetReq:
properties: properties:
display_order: display_order:
@ -2602,6 +2960,48 @@ paths:
summary: List account deletion requests summary: List account deletion requests
tags: tags:
- user - user
/api/v1/analytics/dashboard:
get:
description: 'Platform analytics with optional date filters: all-time (default),
year, year+month, or custom from/to range. When year is set, payments.revenue_monthly
returns JanDec SUCCESS revenue totals (UTC) per currency for that calendar
year — use for yearly revenue charts. Daily series remains in revenue_last_30_days
(see date_filter.series_*). Courses section counts LMS + exam_prep inventory.'
parameters:
- description: Calendar year (e.g. 2025)
in: query
name: year
type: integer
- description: Calendar month 1-12 (requires year)
in: query
name: month
type: integer
- description: Custom range start (YYYY-MM-DD or RFC3339)
in: query
name: from
type: string
- description: Custom range end (YYYY-MM-DD or RFC3339, inclusive)
in: query
name: to
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.AnalyticsDashboard'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Analytics dashboard
tags:
- analytics
/api/v1/assessment/questions: /api/v1/assessment/questions:
get: get:
description: Returns all active assessment questions from the initial assessment description: Returns all active assessment questions from the initial assessment
@ -7617,7 +8017,8 @@ paths:
get: get:
consumes: consumes:
- application/json - application/json
description: Get users with optional filters description: Get users with optional filters. Each user may include active_subscription
when they have a current ACTIVE, non-expired plan.
parameters: parameters:
- description: Role filter - description: Role filter
in: query in: query
@ -7824,6 +8225,71 @@ paths:
summary: Create a TUS resumable upload to Vimeo summary: Create a TUS resumable upload to Vimeo
tags: tags:
- Vimeo - Vimeo
/api/v1/vimeo/videos:
get:
consumes:
- application/json
description: Returns a paginated list of videos for the Vimeo API token (GET
https://api.vimeo.com/me/videos)
parameters:
- default: 1
description: Page number (starts at 1)
in: query
name: page
type: integer
- default: 25
description: Page size (Vimeo max 100)
in: query
name: per_page
type: integer
- description: Search query
in: query
name: query
type: string
- description: Sort field (e.g. date, alphabetical, plays, likes, comments,
duration, relevance)
in: query
name: sort
type: string
- description: asc or desc
in: query
name: direction
type: string
- description: Vimeo filter (e.g. embeddable, playable)
in: query
name: filter
type: string
- description: Vimeo filter_type when using filter
in: query
name: filter_type
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
items:
$ref: '#/definitions/handlers.VimeoVideoResponse'
type: array
metadata:
$ref: '#/definitions/handlers.VimeoVideosListMetadata'
type: object
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List videos stored in the Vimeo account
tags:
- Vimeo
/api/v1/vimeo/videos/{video_id}: /api/v1/vimeo/videos/{video_id}:
delete: delete:
consumes: consumes:

View File

@ -14,22 +14,58 @@ import (
const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one
SELECT SELECT
0::bigint AS total_categories, (SELECT COUNT(*)::bigint FROM programs) AS total_categories,
0::bigint AS total_courses, (SELECT COUNT(*)::bigint FROM courses) AS total_courses,
0::bigint AS total_sub_courses, (SELECT COUNT(*)::bigint FROM modules) AS total_sub_courses,
0::bigint AS total_videos (
COALESCE((SELECT COUNT(*)::bigint FROM lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL), 0::bigint)
+ COALESCE((SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL), 0::bigint)
)::bigint AS total_videos,
(SELECT COUNT(*)::bigint FROM programs) AS lms_programs,
(SELECT COUNT(*)::bigint FROM courses) AS lms_courses,
(SELECT COUNT(*)::bigint FROM modules) AS lms_modules,
(SELECT COUNT(*)::bigint FROM lessons) AS lms_lessons,
(SELECT COUNT(*)::bigint FROM lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS lms_lessons_with_video,
(SELECT COUNT(*)::bigint FROM lms_practices) AS lms_practices,
(SELECT COUNT(*)::bigint FROM lms_practices WHERE course_id IS NOT NULL) AS lms_practices_at_course,
(SELECT COUNT(*)::bigint FROM lms_practices WHERE module_id IS NOT NULL) AS lms_practices_at_module,
(SELECT COUNT(*)::bigint FROM lms_practices WHERE lesson_id IS NOT NULL) AS lms_practices_at_lesson,
(SELECT COUNT(*)::bigint FROM exam_prep.catalog_courses) AS exam_prep_catalog_courses,
(SELECT COUNT(*)::bigint FROM exam_prep.units) AS exam_prep_units,
(SELECT COUNT(*)::bigint FROM exam_prep.unit_modules) AS exam_prep_unit_modules,
(SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons) AS exam_prep_lessons,
(SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS exam_prep_lessons_with_video,
(SELECT COUNT(*)::bigint FROM exam_prep.lesson_practices) AS exam_prep_lesson_practices
` `
type AnalyticsCourseCountsRow struct { type AnalyticsCourseCountsRow struct {
TotalCategories int64 `json:"total_categories"` TotalCategories int64 `json:"total_categories"`
TotalCourses int64 `json:"total_courses"` TotalCourses int64 `json:"total_courses"`
TotalSubCourses int64 `json:"total_sub_courses"` TotalSubCourses int64 `json:"total_sub_courses"`
TotalVideos int64 `json:"total_videos"` TotalVideos int64 `json:"total_videos"`
LmsPrograms int64 `json:"lms_programs"`
LmsCourses int64 `json:"lms_courses"`
LmsModules int64 `json:"lms_modules"`
LmsLessons int64 `json:"lms_lessons"`
LmsLessonsWithVideo int64 `json:"lms_lessons_with_video"`
LmsPractices int64 `json:"lms_practices"`
LmsPracticesAtCourse int64 `json:"lms_practices_at_course"`
LmsPracticesAtModule int64 `json:"lms_practices_at_module"`
LmsPracticesAtLesson int64 `json:"lms_practices_at_lesson"`
ExamPrepCatalogCourses int64 `json:"exam_prep_catalog_courses"`
ExamPrepUnits int64 `json:"exam_prep_units"`
ExamPrepUnitModules int64 `json:"exam_prep_unit_modules"`
ExamPrepLessons int64 `json:"exam_prep_lessons"`
ExamPrepLessonsWithVideo int64 `json:"exam_prep_lessons_with_video"`
ExamPrepLessonPractices int64 `json:"exam_prep_lesson_practices"`
} }
// ===================== // =====================
// Course Analytics // Course Analytics
// ===================== // =====================
// LMS: programs -> courses -> modules -> lessons; lms_practices attach to course, module, or lesson.
// Exam prep: exam_prep.catalog_courses -> units -> unit_modules -> unit_module_lessons; exam_prep.lesson_practices.
// Legacy dashboard fields map to: programs, courses, modules, and video-bearing lessons (LMS + exam prep).
func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCountsRow, error) { func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCountsRow, error) {
row := q.db.QueryRow(ctx, AnalyticsCourseCounts) row := q.db.QueryRow(ctx, AnalyticsCourseCounts)
var i AnalyticsCourseCountsRow var i AnalyticsCourseCountsRow
@ -38,6 +74,21 @@ func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCou
&i.TotalCourses, &i.TotalCourses,
&i.TotalSubCourses, &i.TotalSubCourses,
&i.TotalVideos, &i.TotalVideos,
&i.LmsPrograms,
&i.LmsCourses,
&i.LmsModules,
&i.LmsLessons,
&i.LmsLessonsWithVideo,
&i.LmsPractices,
&i.LmsPracticesAtCourse,
&i.LmsPracticesAtModule,
&i.LmsPracticesAtLesson,
&i.ExamPrepCatalogCourses,
&i.ExamPrepUnits,
&i.ExamPrepUnitModules,
&i.ExamPrepLessons,
&i.ExamPrepLessonsWithVideo,
&i.ExamPrepLessonPractices,
) )
return i, err return i, err
} }
@ -677,6 +728,68 @@ func (q *Queries) AnalyticsRevenueLast30Days(ctx context.Context, arg AnalyticsR
return items, nil return items, nil
} }
const AnalyticsRevenueMonthlyByYear = `-- name: AnalyticsRevenueMonthlyByYear :many
WITH months AS (
SELECT bucket
FROM generate_series(
make_timestamptz($1::int, 1, 1, 0, 0, 0, 'UTC'),
make_timestamptz($1::int, 12, 1, 0, 0, 0, 'UTC'),
INTERVAL '1 month'
) AS gs(bucket)
),
by_month_currency AS (
SELECT
date_trunc('month', COALESCE(p.paid_at, p.created_at) AT TIME ZONE 'UTC') AS ym,
p.currency,
SUM(p.amount)::float8 AS total_revenue
FROM payments p
WHERE p.status = 'SUCCESS'
AND EXTRACT(YEAR FROM COALESCE(p.paid_at, p.created_at) AT TIME ZONE 'UTC')::int = $1::int
GROUP BY 1, 2
)
SELECT
(EXTRACT(MONTH FROM m.bucket AT TIME ZONE 'UTC'))::int AS month,
date_trunc('month', m.bucket AT TIME ZONE 'UTC')::date AS month_start,
COALESCE(b.currency, 'ETB'::varchar) AS currency,
COALESCE(b.total_revenue, 0)::float8 AS total_revenue
FROM months m
LEFT JOIN by_month_currency b ON b.ym = date_trunc('month', m.bucket AT TIME ZONE 'UTC')
ORDER BY m.bucket, COALESCE(b.currency, ''::varchar)
`
type AnalyticsRevenueMonthlyByYearRow struct {
Month int32 `json:"month"`
MonthStart pgtype.Date `json:"month_start"`
Currency string `json:"currency"`
TotalRevenue float64 `json:"total_revenue"`
}
// Monthly successful revenue for a calendar year (UTC buckets). Months with multiple currencies emit one row each; months with no revenue emit one row (currency ETB, revenue 0).
func (q *Queries) AnalyticsRevenueMonthlyByYear(ctx context.Context, reportYear int32) ([]AnalyticsRevenueMonthlyByYearRow, error) {
rows, err := q.db.Query(ctx, AnalyticsRevenueMonthlyByYear, reportYear)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AnalyticsRevenueMonthlyByYearRow
for rows.Next() {
var i AnalyticsRevenueMonthlyByYearRow
if err := rows.Scan(
&i.Month,
&i.MonthStart,
&i.Currency,
&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 const AnalyticsSubscriptionsByStatus = `-- name: AnalyticsSubscriptionsByStatus :many
SELECT SELECT
COALESCE(us.status, 'unknown') AS status, COALESCE(us.status, 'unknown') AS status,

View File

@ -578,6 +578,80 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
return items, nil return items, nil
} }
const ListActiveSubscriptionsByUserIDs = `-- name: ListActiveSubscriptionsByUserIDs :many
SELECT DISTINCT ON (us.user_id)
us.user_id,
us.id,
us.plan_id,
us.starts_at,
us.expires_at,
us.status,
us.auto_renew,
us.payment_method,
sp.name AS plan_name,
sp.duration_value,
sp.duration_unit,
sp.price,
sp.currency
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.user_id = ANY($1::bigint[])
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
ORDER BY us.user_id, us.expires_at DESC
`
type ListActiveSubscriptionsByUserIDsRow struct {
UserID int64 `json:"user_id"`
ID int64 `json:"id"`
PlanID int64 `json:"plan_id"`
StartsAt pgtype.Timestamptz `json:"starts_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
Status string `json:"status"`
AutoRenew bool `json:"auto_renew"`
PaymentMethod pgtype.Text `json:"payment_method"`
PlanName string `json:"plan_name"`
DurationValue int32 `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Price pgtype.Numeric `json:"price"`
Currency string `json:"currency"`
}
// One ACTIVE, non-expired row per user (latest expires_at wins), same rules as GetActiveSubscriptionByUserID.
func (q *Queries) ListActiveSubscriptionsByUserIDs(ctx context.Context, dollar_1 []int64) ([]ListActiveSubscriptionsByUserIDsRow, error) {
rows, err := q.db.Query(ctx, ListActiveSubscriptionsByUserIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActiveSubscriptionsByUserIDsRow
for rows.Next() {
var i ListActiveSubscriptionsByUserIDsRow
if err := rows.Scan(
&i.UserID,
&i.ID,
&i.PlanID,
&i.StartsAt,
&i.ExpiresAt,
&i.Status,
&i.AutoRenew,
&i.PaymentMethod,
&i.PlanName,
&i.DurationValue,
&i.DurationUnit,
&i.Price,
&i.Currency,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListSubscriptionPlans = `-- name: ListSubscriptionPlans :many const ListSubscriptionPlans = `-- name: ListSubscriptionPlans :many
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans
WHERE ($1::BOOLEAN IS NULL OR $1 = true AND is_active = true OR $1 = false) WHERE ($1::BOOLEAN IS NULL OR $1 = true AND is_active = true OR $1 = false)

View File

@ -29,6 +29,15 @@ type AnalyticsRevenueTimePoint struct {
Revenue float64 `json:"revenue"` Revenue float64 `json:"revenue"`
} }
// AnalyticsMonthlyRevenuePoint is one calendar month bucket (UTC month start) within a dashboard year query.
type AnalyticsMonthlyRevenuePoint struct {
Month int `json:"month"` // 112
MonthStart time.Time `json:"month_start"` // UTC date of month start (for sorting / tooltips)
Label string `json:"label"` // Short English month label, e.g. Jan
Currency string `json:"currency"`
Revenue float64 `json:"revenue"` // SUCCESS payments aggregate for that bucket
}
type AnalyticsUsersSection struct { type AnalyticsUsersSection struct {
TotalUsers int64 `json:"total_users"` TotalUsers int64 `json:"total_users"`
NewToday int64 `json:"new_today"` NewToday int64 `json:"new_today"`
@ -67,13 +76,45 @@ type AnalyticsPaymentsSection struct {
ByMethod []AnalyticsLabelAmount `json:"by_method"` ByMethod []AnalyticsLabelAmount `json:"by_method"`
RevenueLast30Days []AnalyticsRevenueTimePoint `json:"revenue_last_30_days"` RevenueLast30Days []AnalyticsRevenueTimePoint `json:"revenue_last_30_days"`
// RevenueMonthly is populated only when the request includes year=..., with 12 months (possibly multiple currencies per month).
RevenueMonthly []AnalyticsMonthlyRevenuePoint `json:"revenue_monthly,omitempty"`
// MonthlyRevenueYear is set when RevenueMonthly is non-empty (the calendar year of those buckets).
MonthlyRevenueYear *int `json:"monthly_revenue_year,omitempty"`
}
// AnalyticsLMSContentCounts reflects the LMS hierarchy (Learn English): programs → courses → modules → lessons.
type AnalyticsLMSContentCounts struct {
Programs int64 `json:"programs"`
Courses int64 `json:"courses"`
Modules int64 `json:"modules"`
Lessons int64 `json:"lessons"`
LessonsWithVideo int64 `json:"lessons_with_video"`
Practices int64 `json:"practices"`
PracticesAtCourse int64 `json:"practices_at_course"`
PracticesAtModule int64 `json:"practices_at_module"`
PracticesAtLesson int64 `json:"practices_at_lesson"`
}
// AnalyticsExamPrepContentCounts reflects the exam_prep schema: catalog_courses → units → unit_modules → lessons → lesson_practices.
type AnalyticsExamPrepContentCounts struct {
CatalogCourses int64 `json:"catalog_courses"`
Units int64 `json:"units"`
UnitModules int64 `json:"unit_modules"`
Lessons int64 `json:"lessons"`
LessonsWithVideo int64 `json:"lessons_with_video"`
LessonPractices int64 `json:"lesson_practices"`
} }
type AnalyticsCoursesSection struct { type AnalyticsCoursesSection struct {
// Top-level keys preserved for existing clients: map to LMS programs, courses, modules, and all video lessons (LMS + exam prep).
TotalCategories int64 `json:"total_categories"` TotalCategories int64 `json:"total_categories"`
TotalCourses int64 `json:"total_courses"` TotalCourses int64 `json:"total_courses"`
TotalSubCourses int64 `json:"total_sub_courses"` TotalSubCourses int64 `json:"total_sub_courses"`
TotalVideos int64 `json:"total_videos"` TotalVideos int64 `json:"total_videos"`
LMS AnalyticsLMSContentCounts `json:"lms"`
ExamPrep AnalyticsExamPrepContentCounts `json:"exam_prep"`
} }
type AnalyticsContentSection struct { type AnalyticsContentSection struct {

View File

@ -56,6 +56,54 @@ type UserSubscription struct {
Currency *string Currency *string
} }
// UserSubscriptionSummary is the active subscription attached to admin user list responses (GET /users).
type UserSubscriptionSummary struct {
ID int64 `json:"id"`
PlanID int64 `json:"plan_id"`
PlanName string `json:"plan_name"`
Status string `json:"status"`
StartsAt time.Time `json:"starts_at"`
ExpiresAt time.Time `json:"expires_at"`
AutoRenew bool `json:"auto_renew"`
PaymentMethod *string `json:"payment_method,omitempty"`
DurationValue int32 `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Price float64 `json:"price"`
Currency string `json:"currency"`
}
// Summary returns a copy safe for JSON embedding; nil if receiver is nil.
func (us *UserSubscription) Summary() *UserSubscriptionSummary {
if us == nil {
return nil
}
s := &UserSubscriptionSummary{
ID: us.ID,
PlanID: us.PlanID,
Status: us.Status,
StartsAt: us.StartsAt,
ExpiresAt: us.ExpiresAt,
AutoRenew: us.AutoRenew,
PaymentMethod: us.PaymentMethod,
}
if us.PlanName != nil {
s.PlanName = *us.PlanName
}
if us.DurationValue != nil {
s.DurationValue = *us.DurationValue
}
if us.DurationUnit != nil {
s.DurationUnit = *us.DurationUnit
}
if us.Price != nil {
s.Price = *us.Price
}
if us.Currency != nil {
s.Currency = *us.Currency
}
return s
}
type CreateSubscriptionPlanInput struct { type CreateSubscriptionPlanInput struct {
Name string Name string
Description *string Description *string

View File

@ -120,6 +120,8 @@ type UserProfileResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
ActiveSubscription *UserSubscriptionSummary `json:"active_subscription,omitempty"`
} }
type UserFilter struct { type UserFilter struct {

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"time" "time"
) )
@ -141,6 +142,35 @@ type UpdateVideoRequest struct {
Privacy *PrivacyParams `json:"privacy,omitempty"` Privacy *PrivacyParams `json:"privacy,omitempty"`
} }
// ListVideosParams configures GET /me/videos (authenticated users library).
// See https://developer.vimeo.com/api/reference/videos#get_videos
type ListVideosParams struct {
Page int // 1-based; omitted when 0
PerPage int // max 100; omitted when 0
Query string // optional search filter
Sort string // e.g. date, alphabetical, plays, likes, comments, duration, relevance
Direction string // asc or desc
Filter string // optional: embeddable, playable, playable_in_subscription, etc.
FilterType string // optional: 8 for staff picks (when using filter)
}
// ListVideosResponse is the JSON envelope Vimeo returns for list endpoints.
type ListVideosResponse struct {
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Paging PagingLinks `json:"paging"`
Data []Video `json:"data"`
}
// PagingLinks contains cursor URLs for the next/previous page from Vimeo.
type PagingLinks struct {
Next string `json:"next"`
Previous string `json:"previous"`
First string `json:"first"`
Last string `json:"last"`
}
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
var reqBody io.Reader var reqBody io.Reader
if body != nil { if body != nil {
@ -185,6 +215,55 @@ func (c *Client) GetVideo(ctx context.Context, videoID string) (*Video, error) {
return &video, nil return &video, nil
} }
// ListMyVideos calls GET /me/videos for the tokens Vimeo account.
func (c *Client) ListMyVideos(ctx context.Context, params ListVideosParams) (*ListVideosResponse, error) {
q := url.Values{}
if params.Page > 0 {
q.Set("page", strconv.Itoa(params.Page))
}
if params.PerPage > 0 {
q.Set("per_page", strconv.Itoa(params.PerPage))
}
if params.Query != "" {
q.Set("query", params.Query)
}
if params.Sort != "" {
q.Set("sort", params.Sort)
}
if params.Direction != "" {
q.Set("direction", params.Direction)
}
if params.Filter != "" {
q.Set("filter", params.Filter)
}
if params.FilterType != "" {
q.Set("filter_type", params.FilterType)
}
path := "/me/videos"
if enc := q.Encode(); enc != "" {
path += "?" + enc
}
resp, err := c.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to list videos: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
var out ListVideosResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("failed to decode list videos response: %w", err)
}
return &out, nil
}
func (c *Client) CreateUpload(ctx context.Context, req *UploadRequest) (*UploadResponse, error) { func (c *Client) CreateUpload(ctx context.Context, req *UploadRequest) (*UploadResponse, error) {
resp, err := c.doRequest(ctx, http.MethodPost, "/me/videos", req) resp, err := c.doRequest(ctx, http.MethodPost, "/me/videos", req)
if err != nil { if err != nil {

View File

@ -18,6 +18,7 @@ type SubscriptionStore interface {
CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error) CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error)
GetUserSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error) GetUserSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error)
GetActiveSubscriptionByUserID(ctx context.Context, userID int64) (*domain.UserSubscription, error) GetActiveSubscriptionByUserID(ctx context.Context, userID int64) (*domain.UserSubscription, error)
ListActiveSubscriptionsByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error)
GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error)
HasActiveSubscription(ctx context.Context, userID int64) (bool, error) HasActiveSubscription(ctx context.Context, userID int64) (bool, error)
CancelUserSubscription(ctx context.Context, id int64) error CancelUserSubscription(ctx context.Context, id int64) error

View File

@ -157,6 +157,39 @@ func (s *Store) GetActiveSubscriptionByUserID(ctx context.Context, userID int64)
}, nil }, nil
} }
func (s *Store) ListActiveSubscriptionsByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error) {
if len(userIDs) == 0 {
return map[int64]*domain.UserSubscription{}, nil
}
rows, err := s.queries.ListActiveSubscriptionsByUserIDs(ctx, userIDs)
if err != nil {
return nil, err
}
out := make(map[int64]*domain.UserSubscription, len(rows))
for _, r := range rows {
dv := r.DurationValue
du := r.DurationUnit
pn := r.PlanName
cur := r.Currency
out[r.UserID] = &domain.UserSubscription{
ID: r.ID,
UserID: r.UserID,
PlanID: r.PlanID,
StartsAt: r.StartsAt.Time,
ExpiresAt: r.ExpiresAt.Time,
Status: r.Status,
AutoRenew: r.AutoRenew,
PaymentMethod: fromPgText(r.PaymentMethod),
PlanName: &pn,
DurationValue: &dv,
DurationUnit: &du,
Price: float64Ptr(fromPgNumeric(r.Price)),
Currency: &cur,
}
}
return out, nil
}
func (s *Store) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) { func (s *Store) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {
subs, err := s.queries.GetUserSubscriptionHistory(ctx, dbgen.GetUserSubscriptionHistoryParams{ subs, err := s.queries.GetUserSubscriptionHistory(ctx, dbgen.GetUserSubscriptionHistoryParams{
UserID: userID, UserID: userID,

View File

@ -243,6 +243,7 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"}, {Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"},
// Vimeo // Vimeo
{Key: "vimeo.videos.list", Name: "List Vimeo Videos", Description: "List videos in the Vimeo account", GroupName: "Vimeo"},
{Key: "vimeo.videos.get", Name: "Get Vimeo Video", Description: "Get Vimeo video details", GroupName: "Vimeo"}, {Key: "vimeo.videos.get", Name: "Get Vimeo Video", Description: "Get Vimeo video details", GroupName: "Vimeo"},
{Key: "vimeo.videos.embed", Name: "Get Embed Code", Description: "Get Vimeo embed code", GroupName: "Vimeo"}, {Key: "vimeo.videos.embed", Name: "Get Embed Code", Description: "Get Vimeo embed code", GroupName: "Vimeo"},
{Key: "vimeo.videos.status", Name: "Get Transcode Status", Description: "Get Vimeo transcode status", GroupName: "Vimeo"}, {Key: "vimeo.videos.status", Name: "Get Transcode Status", Description: "Get Vimeo transcode status", GroupName: "Vimeo"},
@ -380,7 +381,7 @@ var DefaultRolePermissions = map[string][]string{
"analytics.dashboard", "analytics.dashboard",
// Vimeo // Vimeo
"vimeo.videos.get", "vimeo.videos.embed", "vimeo.videos.status", "vimeo.videos.delete", "vimeo.videos.list", "vimeo.videos.get", "vimeo.videos.embed", "vimeo.videos.status", "vimeo.videos.delete",
"vimeo.uploads.pull", "vimeo.uploads.tus", "vimeo.uploads.pull", "vimeo.uploads.tus",
// Team (full access) // Team (full access)

View File

@ -107,6 +107,11 @@ func (s *Service) GetActiveSubscription(ctx context.Context, userID int64) (*dom
return s.store.GetActiveSubscriptionByUserID(ctx, userID) return s.store.GetActiveSubscriptionByUserID(ctx, userID)
} }
// ListActiveSubscriptionsForUserIDs returns the current ACTIVE, non-expired subscription per user (latest expiry).
func (s *Service) ListActiveSubscriptionsForUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error) {
return s.store.ListActiveSubscriptionsByUserIDs(ctx, userIDs)
}
func (s *Service) GetSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) { func (s *Service) GetSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {
return s.store.GetUserSubscriptionHistory(ctx, userID, limit, offset) return s.store.GetUserSubscriptionHistory(ctx, userID, limit, offset)
} }

View File

@ -45,6 +45,15 @@ type UploadResult struct {
Status string Status string
} }
// ListVideosPage is the service result for a paginated Vimeo library query.
type ListVideosPage struct {
Total int
Page int
PerPage int
Paging vimeo.PagingLinks
Videos []*VideoInfo
}
func (s *Service) GetVideoInfo(ctx context.Context, videoID string) (*VideoInfo, error) { func (s *Service) GetVideoInfo(ctx context.Context, videoID string) (*VideoInfo, error) {
video, err := s.client.GetVideo(ctx, videoID) video, err := s.client.GetVideo(ctx, videoID)
if err != nil { if err != nil {
@ -52,6 +61,18 @@ func (s *Service) GetVideoInfo(ctx context.Context, videoID string) (*VideoInfo,
return nil, fmt.Errorf("failed to get video: %w", err) return nil, fmt.Errorf("failed to get video: %w", err)
} }
return s.videoModelToInfo(video, videoID), nil
}
func (s *Service) videoModelToInfo(video *vimeo.Video, fallbackID string) *VideoInfo {
videoID := fallbackID
if videoID == "" {
videoID = vimeo.ExtractVideoID(video.URI)
}
if videoID == "" {
videoID = vimeo.ExtractVideoID(video.Link)
}
info := &VideoInfo{ info := &VideoInfo{
VimeoID: videoID, VimeoID: videoID,
URI: video.URI, URI: video.URI,
@ -66,7 +87,7 @@ func (s *Service) GetVideoInfo(ctx context.Context, videoID string) (*VideoInfo,
if video.PlayerEmbedURL != "" { if video.PlayerEmbedURL != "" {
info.EmbedURL = video.PlayerEmbedURL info.EmbedURL = video.PlayerEmbedURL
} else { } else if videoID != "" {
info.EmbedURL = vimeo.GenerateEmbedURL(videoID, nil) info.EmbedURL = vimeo.GenerateEmbedURL(videoID, nil)
} }
@ -82,7 +103,28 @@ func (s *Service) GetVideoInfo(ctx context.Context, videoID string) (*VideoInfo,
info.TranscodeStatus = video.Transcode.Status info.TranscodeStatus = video.Transcode.Status
} }
return info, nil return info
}
func (s *Service) ListVideos(ctx context.Context, params vimeo.ListVideosParams) (*ListVideosPage, error) {
raw, err := s.client.ListMyVideos(ctx, params)
if err != nil {
s.logger.Error("Failed to list Vimeo videos", zap.Error(err))
return nil, fmt.Errorf("failed to list videos: %w", err)
}
out := &ListVideosPage{
Total: raw.Total,
Page: raw.Page,
PerPage: raw.PerPage,
Paging: raw.Paging,
Videos: make([]*VideoInfo, 0, len(raw.Data)),
}
for i := range raw.Data {
out.Videos = append(out.Videos, s.videoModelToInfo(&raw.Data[i], ""))
}
return out, nil
} }
func (s *Service) CreatePullUpload(ctx context.Context, name, description, sourceURL string, fileSize int64) (*UploadResult, error) { func (s *Service) CreatePullUpload(ctx context.Context, name, description, sourceURL string, fileSize int64) (*UploadResult, error) {

View File

@ -8,6 +8,11 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// Short month labels for analytics monthly charts (aligned with UTC calendar months).
var analyticsShortMonthLabels = []string{
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
}
func toTime(v interface{}) time.Time { func toTime(v interface{}) time.Time {
if t, ok := v.(time.Time); ok { if t, ok := v.(time.Time); ok {
return t return t
@ -17,7 +22,7 @@ func toTime(v interface{}) time.Time {
// GetAnalyticsDashboard godoc // GetAnalyticsDashboard godoc
// @Summary Analytics dashboard // @Summary Analytics dashboard
// @Description Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. // @Description Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. When year is set, payments.revenue_monthly returns JanDec SUCCESS revenue totals (UTC) per currency for that calendar year — use for yearly revenue charts. Daily series remains in revenue_last_30_days (see date_filter.series_*). Courses section counts LMS + exam_prep inventory.
// @Tags analytics // @Tags analytics
// @Produce json // @Produce json
// @Param year query int false "Calendar year (e.g. 2025)" // @Param year query int false "Calendar year (e.g. 2025)"
@ -103,6 +108,17 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue time series") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue time series")
} }
var revenueMonthlyRows []dbgen.AnalyticsRevenueMonthlyByYearRow
var monthlyRevenueYear *int
if filter.Year != nil {
rowsMonthly, err := h.analyticsDB.AnalyticsRevenueMonthlyByYear(ctx, int32(*filter.Year))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch monthly revenue series")
}
revenueMonthlyRows = rowsMonthly
monthlyRevenueYear = filter.Year
}
courseCounts, err := h.analyticsDB.AnalyticsCourseCounts(ctx) courseCounts, err := h.analyticsDB.AnalyticsCourseCounts(ctx)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch course analytics") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch course analytics")
@ -165,13 +181,8 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
DateFilter: filter, DateFilter: filter,
Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs), Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs),
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30), Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear),
Courses: domain.AnalyticsCoursesSection{ Courses: mapCoursesSection(courseCounts),
TotalCategories: courseCounts.TotalCategories,
TotalCourses: courseCounts.TotalCourses,
TotalSubCourses: courseCounts.TotalSubCourses,
TotalVideos: courseCounts.TotalVideos,
},
Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType), Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType),
Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType), Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType),
Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType), Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType),
@ -181,6 +192,34 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
return c.JSON(dashboard) return c.JSON(dashboard)
} }
func mapCoursesSection(r dbgen.AnalyticsCourseCountsRow) domain.AnalyticsCoursesSection {
return domain.AnalyticsCoursesSection{
TotalCategories: r.TotalCategories,
TotalCourses: r.TotalCourses,
TotalSubCourses: r.TotalSubCourses,
TotalVideos: r.TotalVideos,
LMS: domain.AnalyticsLMSContentCounts{
Programs: r.LmsPrograms,
Courses: r.LmsCourses,
Modules: r.LmsModules,
Lessons: r.LmsLessons,
LessonsWithVideo: r.LmsLessonsWithVideo,
Practices: r.LmsPractices,
PracticesAtCourse: r.LmsPracticesAtCourse,
PracticesAtModule: r.LmsPracticesAtModule,
PracticesAtLesson: r.LmsPracticesAtLesson,
},
ExamPrep: domain.AnalyticsExamPrepContentCounts{
CatalogCourses: r.ExamPrepCatalogCourses,
Units: r.ExamPrepUnits,
UnitModules: r.ExamPrepUnitModules,
Lessons: r.ExamPrepLessons,
LessonsWithVideo: r.ExamPrepLessonsWithVideo,
LessonPractices: r.ExamPrepLessonPractices,
},
}
}
func mapUsersSection( func mapUsersSection(
summary dbgen.AnalyticsUsersSummaryRow, summary dbgen.AnalyticsUsersSummaryRow,
byRole []dbgen.AnalyticsUsersByRoleRow, byRole []dbgen.AnalyticsUsersByRoleRow,
@ -263,6 +302,8 @@ func mapPaymentsSection(
byStatus []dbgen.AnalyticsPaymentsByStatusRow, byStatus []dbgen.AnalyticsPaymentsByStatusRow,
byMethod []dbgen.AnalyticsPaymentsByMethodRow, byMethod []dbgen.AnalyticsPaymentsByMethodRow,
revenue []dbgen.AnalyticsRevenueLast30DaysRow, revenue []dbgen.AnalyticsRevenueLast30DaysRow,
revenueMonthly []dbgen.AnalyticsRevenueMonthlyByYearRow,
monthlyYear *int,
) domain.AnalyticsPaymentsSection { ) domain.AnalyticsPaymentsSection {
statuses := make([]domain.AnalyticsLabelAmount, len(byStatus)) statuses := make([]domain.AnalyticsLabelAmount, len(byStatus))
for i, r := range byStatus { for i, r := range byStatus {
@ -276,6 +317,27 @@ func mapPaymentsSection(
for i, r := range revenue { for i, r := range revenue {
timePoints[i] = domain.AnalyticsRevenueTimePoint{Date: toTime(r.Date), Revenue: r.TotalRevenue} timePoints[i] = domain.AnalyticsRevenueTimePoint{Date: toTime(r.Date), Revenue: r.TotalRevenue}
} }
monthlyPoints := make([]domain.AnalyticsMonthlyRevenuePoint, 0, len(revenueMonthly))
for _, r := range revenueMonthly {
m := int(r.Month)
label := ""
if m >= 1 && m <= 12 {
label = analyticsShortMonthLabels[m-1]
}
ms := time.Time{}
if r.MonthStart.Valid {
t := r.MonthStart.Time.UTC()
ms = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
monthlyPoints = append(monthlyPoints, domain.AnalyticsMonthlyRevenuePoint{
Month: m,
MonthStart: ms,
Label: label,
Currency: r.Currency,
Revenue: r.TotalRevenue,
})
}
return domain.AnalyticsPaymentsSection{ return domain.AnalyticsPaymentsSection{
TotalRevenue: summary.TotalRevenue, TotalRevenue: summary.TotalRevenue,
AvgTransactionValue: summary.AvgValue, AvgTransactionValue: summary.AvgValue,
@ -284,6 +346,8 @@ func mapPaymentsSection(
ByStatus: statuses, ByStatus: statuses,
ByMethod: methods, ByMethod: methods,
RevenueLast30Days: timePoints, RevenueLast30Days: timePoints,
RevenueMonthly: monthlyPoints,
MonthlyRevenueYear: monthlyYear,
} }
} }

View File

@ -423,7 +423,7 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
// GetAllUsers godoc // GetAllUsers godoc
// @Summary Get all users // @Summary Get all users
// @Description Get users with optional filters // @Description Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.
// @Tags user // @Tags user
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -499,6 +499,19 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get users: "+err.Error()) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get users: "+err.Error())
} }
userIDs := make([]int64, len(users))
for i, u := range users {
userIDs[i] = u.ID
}
activeSubs, err := h.subscriptionsSvc.ListActiveSubscriptionsForUserIDs(c.Context(), userIDs)
if err != nil {
h.mongoLoggerSvc.Error("failed to batch-load active subscriptions for user list",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()))
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get subscription info: "+err.Error())
}
// Map to profile response to avoid leaking sensitive fields // Map to profile response to avoid leaking sensitive fields
// result := make([]domain.UserProfileResponse, len(users)) // result := make([]domain.UserProfileResponse, len(users))
// for i, u := range users { // for i, u := range users {
@ -538,6 +551,11 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
if !u.BirthDay.IsZero() { if !u.BirthDay.IsZero() {
bd = u.BirthDay.Format("2006-01-02") bd = u.BirthDay.Format("2006-01-02")
} }
var activeSub *domain.UserSubscriptionSummary
if sub, ok := activeSubs[u.ID]; ok {
activeSub = sub.Summary()
}
mapped = append(mapped, domain.UserProfileResponse{ mapped = append(mapped, domain.UserProfileResponse{
ID: u.ID, ID: u.ID,
FirstName: u.FirstName, FirstName: u.FirstName,
@ -567,6 +585,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
PreferredLanguage: u.PreferredLanguage, PreferredLanguage: u.PreferredLanguage,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt, UpdatedAt: u.UpdatedAt,
ActiveSubscription: activeSub,
}) })
} }

View File

@ -3,6 +3,7 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/pkgs/vimeo" "Yimaru-Backend/internal/pkgs/vimeo"
vimeoservice "Yimaru-Backend/internal/services/vimeo"
"fmt" "fmt"
"strconv" "strconv"
@ -62,6 +63,124 @@ type VimeoEmbedResponse struct {
EmbedHTML string `json:"embed_html"` EmbedHTML string `json:"embed_html"`
} }
type VimeoVideosListMetadata struct {
domain.Pagination
Next string `json:"next,omitempty"`
Previous string `json:"previous,omitempty"`
First string `json:"first,omitempty"`
Last string `json:"last,omitempty"`
}
func vimeoVideoInfoToResponse(info *vimeoservice.VideoInfo) VimeoVideoResponse {
return VimeoVideoResponse{
VimeoID: info.VimeoID,
URI: info.URI,
Name: info.Name,
Description: info.Description,
Duration: info.Duration,
Width: info.Width,
Height: info.Height,
Link: info.Link,
EmbedURL: info.EmbedURL,
EmbedHTML: info.EmbedHTML,
ThumbnailURL: info.ThumbnailURL,
Status: info.Status,
TranscodeStatus: info.TranscodeStatus,
}
}
// ListVimeoVideos godoc
// @Summary List videos stored in the Vimeo account
// @Description Returns a paginated list of videos for the Vimeo API token (GET https://api.vimeo.com/me/videos)
// @Tags Vimeo
// @Accept json
// @Produce json
// @Param page query int false "Page number (starts at 1)" default(1)
// @Param per_page query int false "Page size (Vimeo max 100)" default(25)
// @Param query query string false "Search query"
// @Param sort query string false "Sort field (e.g. date, alphabetical, plays, likes, comments, duration, relevance)"
// @Param direction query string false "asc or desc"
// @Param filter query string false "Vimeo filter (e.g. embeddable, playable)"
// @Param filter_type query string false "Vimeo filter_type when using filter"
// @Success 200 {object} domain.Response{data=[]handlers.VimeoVideoResponse,metadata=handlers.VimeoVideosListMetadata}
// @Failure 503 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/vimeo/videos [get]
func (h *Handler) ListVimeoVideos(c *fiber.Ctx) error {
if h.vimeoSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "Vimeo service is not configured",
Error: "Vimeo service is not enabled or missing access token",
})
}
page, _ := strconv.Atoi(c.Query("page", "1"))
perPage, _ := strconv.Atoi(c.Query("per_page", "25"))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 25
}
if perPage > 100 {
perPage = 100
}
params := vimeo.ListVideosParams{
Page: page,
PerPage: perPage,
Query: c.Query("query"),
Sort: c.Query("sort"),
Direction: c.Query("direction"),
Filter: c.Query("filter"),
FilterType: c.Query("filter_type"),
}
pageResult, err := h.vimeoSvc.ListVideos(c.Context(), params)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list Vimeo videos",
Error: err.Error(),
})
}
items := make([]VimeoVideoResponse, 0, len(pageResult.Videos))
for _, info := range pageResult.Videos {
if info == nil {
continue
}
items = append(items, vimeoVideoInfoToResponse(info))
}
totalPages := 0
if pageResult.PerPage > 0 && pageResult.Total > 0 {
totalPages = (pageResult.Total + pageResult.PerPage - 1) / pageResult.PerPage
}
currentPage := pageResult.Page
if currentPage < 1 {
currentPage = page
}
return c.JSON(domain.Response{
Message: "Vimeo videos listed successfully",
Data: items,
MetaData: VimeoVideosListMetadata{
Pagination: domain.Pagination{
Total: pageResult.Total,
TotalPages: totalPages,
CurrentPage: currentPage,
Limit: pageResult.PerPage,
},
Next: pageResult.Paging.Next,
Previous: pageResult.Paging.Previous,
First: pageResult.Paging.First,
Last: pageResult.Paging.Last,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetVimeoVideo godoc // GetVimeoVideo godoc
// @Summary Get video information from Vimeo // @Summary Get video information from Vimeo
// @Description Retrieves video details from Vimeo by video ID // @Description Retrieves video details from Vimeo by video ID
@ -98,22 +217,8 @@ func (h *Handler) GetVimeoVideo(c *fiber.Ctx) error {
} }
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Video retrieved successfully", Message: "Video retrieved successfully",
Data: VimeoVideoResponse{ Data: vimeoVideoInfoToResponse(info),
VimeoID: info.VimeoID,
URI: info.URI,
Name: info.Name,
Description: info.Description,
Duration: info.Duration,
Width: info.Width,
Height: info.Height,
Link: info.Link,
EmbedURL: info.EmbedURL,
EmbedHTML: info.EmbedHTML,
ThumbnailURL: info.ThumbnailURL,
Status: info.Status,
TranscodeStatus: info.TranscodeStatus,
},
Success: true, Success: true,
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
@ -413,21 +518,7 @@ func (h *Handler) GetSampleVideo(c *fiber.Ctx) error {
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Sample video retrieved successfully", Message: "Sample video retrieved successfully",
Data: fiber.Map{ Data: fiber.Map{
"video": VimeoVideoResponse{ "video": vimeoVideoInfoToResponse(info),
VimeoID: info.VimeoID,
URI: info.URI,
Name: info.Name,
Description: info.Description,
Duration: info.Duration,
Width: info.Width,
Height: info.Height,
Link: info.Link,
EmbedURL: info.EmbedURL,
EmbedHTML: info.EmbedHTML,
ThumbnailURL: info.ThumbnailURL,
Status: info.Status,
TranscodeStatus: info.TranscodeStatus,
},
"iframe": iframe, "iframe": iframe,
}, },
Success: true, Success: true,

View File

@ -357,6 +357,7 @@ func (a *App) initAppRoutes() {
// Vimeo // Vimeo
vimeoGroup := groupV1.Group("/vimeo") vimeoGroup := groupV1.Group("/vimeo")
vimeoGroup.Get("/videos", a.authMiddleware, a.RequirePermission("vimeo.videos.list"), h.ListVimeoVideos)
vimeoGroup.Get("/videos/:video_id", a.authMiddleware, a.RequirePermission("vimeo.videos.get"), h.GetVimeoVideo) vimeoGroup.Get("/videos/:video_id", a.authMiddleware, a.RequirePermission("vimeo.videos.get"), h.GetVimeoVideo)
vimeoGroup.Get("/videos/:video_id/embed", a.authMiddleware, a.RequirePermission("vimeo.videos.embed"), h.GetEmbedCode) vimeoGroup.Get("/videos/:video_id/embed", a.authMiddleware, a.RequirePermission("vimeo.videos.embed"), h.GetEmbedCode)
vimeoGroup.Get("/videos/:video_id/status", a.authMiddleware, a.RequirePermission("vimeo.videos.status"), h.GetTranscodeStatus) vimeoGroup.Get("/videos/:video_id/status", a.authMiddleware, a.RequirePermission("vimeo.videos.status"), h.GetTranscodeStatus)