Compare commits
4 Commits
9afc9a4392
...
f824c16c64
| Author | SHA1 | Date | |
|---|---|---|---|
| f824c16c64 | |||
| 2883561525 | |||
| a1696bf1e0 | |||
| 7f8ef3373c |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.*,
|
||||||
|
|
|
||||||
698
docs/docs.go
698
docs/docs.go
|
|
@ -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 Jan–Dec 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": "1–12",
|
||||||
|
"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": [
|
||||||
|
|
|
||||||
|
|
@ -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 Jan–Dec 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": "1–12",
|
||||||
|
"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": [
|
||||||
|
|
|
||||||
|
|
@ -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: 1–12
|
||||||
|
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 Jan–Dec 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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"` // 1–12
|
||||||
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 user’s 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 token’s 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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 Jan–Dec 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user