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
|
||||
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
|
||||
-- =====================
|
||||
-- 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
|
||||
SELECT
|
||||
0::bigint AS total_categories,
|
||||
0::bigint AS total_courses,
|
||||
0::bigint AS total_sub_courses,
|
||||
0::bigint AS total_videos;
|
||||
(SELECT COUNT(*)::bigint FROM programs) AS total_categories,
|
||||
(SELECT COUNT(*)::bigint FROM courses) AS total_courses,
|
||||
(SELECT COUNT(*)::bigint FROM modules) AS total_sub_courses,
|
||||
(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -61,6 +61,29 @@ FROM user_subscriptions us
|
|||
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||
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
|
||||
SELECT
|
||||
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": {
|
||||
"get": {
|
||||
"description": "Returns all active assessment questions from the initial assessment set",
|
||||
|
|
@ -8378,7 +8436,7 @@ const docTemplate = `{
|
|||
},
|
||||
"/api/v1/users": {
|
||||
"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": [
|
||||
"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}": {
|
||||
"get": {
|
||||
"description": "Retrieves video details from Vimeo by video ID",
|
||||
|
|
@ -9455,6 +9612,472 @@ const docTemplate = `{
|
|||
"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": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -10671,6 +11294,9 @@ const docTemplate = `{
|
|||
"domain.UserProfileResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_subscription": {
|
||||
"$ref": "#/definitions/domain.UserSubscriptionSummary"
|
||||
},
|
||||
"age_group": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -10778,6 +11404,47 @@ const docTemplate = `{
|
|||
"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": {
|
||||
"type": "object",
|
||||
"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": {
|
||||
"type": "object",
|
||||
"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": {
|
||||
"get": {
|
||||
"description": "Returns all active assessment questions from the initial assessment set",
|
||||
|
|
@ -8370,7 +8428,7 @@
|
|||
},
|
||||
"/api/v1/users": {
|
||||
"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": [
|
||||
"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}": {
|
||||
"get": {
|
||||
"description": "Retrieves video details from Vimeo by video ID",
|
||||
|
|
@ -9447,6 +9604,472 @@
|
|||
"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": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -10663,6 +11286,9 @@
|
|||
"domain.UserProfileResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_subscription": {
|
||||
"$ref": "#/definitions/domain.UserSubscriptionSummary"
|
||||
},
|
||||
"age_group": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -10770,6 +11396,47 @@
|
|||
"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": {
|
||||
"type": "object",
|
||||
"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": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -17,6 +17,316 @@ definitions:
|
|||
- Age35To44
|
||||
- Age45To54
|
||||
- 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:
|
||||
properties:
|
||||
description:
|
||||
|
|
@ -835,6 +1145,8 @@ definitions:
|
|||
type: object
|
||||
domain.UserProfileResponse:
|
||||
properties:
|
||||
active_subscription:
|
||||
$ref: '#/definitions/domain.UserSubscriptionSummary'
|
||||
age_group:
|
||||
type: string
|
||||
birth_day:
|
||||
|
|
@ -909,6 +1221,33 @@ definitions:
|
|||
- UserStatusActive
|
||||
- UserStatusSuspended
|
||||
- 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:
|
||||
properties:
|
||||
active_users:
|
||||
|
|
@ -1190,6 +1529,25 @@ definitions:
|
|||
width:
|
||||
type: integer
|
||||
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:
|
||||
properties:
|
||||
display_order:
|
||||
|
|
@ -2602,6 +2960,48 @@ paths:
|
|||
summary: List account deletion requests
|
||||
tags:
|
||||
- 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:
|
||||
get:
|
||||
description: Returns all active assessment questions from the initial assessment
|
||||
|
|
@ -7617,7 +8017,8 @@ paths:
|
|||
get:
|
||||
consumes:
|
||||
- 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:
|
||||
- description: Role filter
|
||||
in: query
|
||||
|
|
@ -7824,6 +8225,71 @@ paths:
|
|||
summary: Create a TUS resumable upload to Vimeo
|
||||
tags:
|
||||
- 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}:
|
||||
delete:
|
||||
consumes:
|
||||
|
|
|
|||
|
|
@ -14,22 +14,58 @@ import (
|
|||
const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one
|
||||
|
||||
SELECT
|
||||
0::bigint AS total_categories,
|
||||
0::bigint AS total_courses,
|
||||
0::bigint AS total_sub_courses,
|
||||
0::bigint AS total_videos
|
||||
(SELECT COUNT(*)::bigint FROM programs) AS total_categories,
|
||||
(SELECT COUNT(*)::bigint FROM courses) AS total_courses,
|
||||
(SELECT COUNT(*)::bigint FROM modules) AS total_sub_courses,
|
||||
(
|
||||
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 {
|
||||
TotalCategories int64 `json:"total_categories"`
|
||||
TotalCourses int64 `json:"total_courses"`
|
||||
TotalSubCourses int64 `json:"total_sub_courses"`
|
||||
TotalVideos int64 `json:"total_videos"`
|
||||
TotalCategories int64 `json:"total_categories"`
|
||||
TotalCourses int64 `json:"total_courses"`
|
||||
TotalSubCourses int64 `json:"total_sub_courses"`
|
||||
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
|
||||
// =====================
|
||||
// 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) {
|
||||
row := q.db.QueryRow(ctx, AnalyticsCourseCounts)
|
||||
var i AnalyticsCourseCountsRow
|
||||
|
|
@ -38,6 +74,21 @@ func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCou
|
|||
&i.TotalCourses,
|
||||
&i.TotalSubCourses,
|
||||
&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
|
||||
}
|
||||
|
|
@ -677,6 +728,68 @@ func (q *Queries) AnalyticsRevenueLast30Days(ctx context.Context, arg AnalyticsR
|
|||
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
|
||||
SELECT
|
||||
COALESCE(us.status, 'unknown') AS status,
|
||||
|
|
|
|||
|
|
@ -578,6 +578,80 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
|
|||
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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ type AnalyticsRevenueTimePoint struct {
|
|||
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 {
|
||||
TotalUsers int64 `json:"total_users"`
|
||||
NewToday int64 `json:"new_today"`
|
||||
|
|
@ -67,13 +76,45 @@ type AnalyticsPaymentsSection struct {
|
|||
ByMethod []AnalyticsLabelAmount `json:"by_method"`
|
||||
|
||||
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 {
|
||||
// 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"`
|
||||
TotalCourses int64 `json:"total_courses"`
|
||||
TotalSubCourses int64 `json:"total_sub_courses"`
|
||||
TotalVideos int64 `json:"total_videos"`
|
||||
|
||||
LMS AnalyticsLMSContentCounts `json:"lms"`
|
||||
ExamPrep AnalyticsExamPrepContentCounts `json:"exam_prep"`
|
||||
}
|
||||
|
||||
type AnalyticsContentSection struct {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,54 @@ type UserSubscription struct {
|
|||
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 {
|
||||
Name string
|
||||
Description *string
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ type UserProfileResponse struct {
|
|||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
|
||||
ActiveSubscription *UserSubscriptionSummary `json:"active_subscription,omitempty"`
|
||||
}
|
||||
|
||||
type UserFilter struct {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -141,6 +142,35 @@ type UpdateVideoRequest struct {
|
|||
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) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
|
|
@ -185,6 +215,55 @@ func (c *Client) GetVideo(ctx context.Context, videoID string) (*Video, error) {
|
|||
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) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/me/videos", req)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type SubscriptionStore interface {
|
|||
CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error)
|
||||
GetUserSubscriptionByID(ctx context.Context, id 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)
|
||||
HasActiveSubscription(ctx context.Context, userID int64) (bool, error)
|
||||
CancelUserSubscription(ctx context.Context, id int64) error
|
||||
|
|
|
|||
|
|
@ -157,6 +157,39 @@ func (s *Store) GetActiveSubscriptionByUserID(ctx context.Context, userID int64)
|
|||
}, 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) {
|
||||
subs, err := s.queries.GetUserSubscriptionHistory(ctx, dbgen.GetUserSubscriptionHistoryParams{
|
||||
UserID: userID,
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ var AllPermissions = []domain.PermissionSeed{
|
|||
{Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"},
|
||||
|
||||
// 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.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"},
|
||||
|
|
@ -380,7 +381,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"analytics.dashboard",
|
||||
|
||||
// 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",
|
||||
|
||||
// Team (full access)
|
||||
|
|
|
|||
|
|
@ -107,6 +107,11 @@ func (s *Service) GetActiveSubscription(ctx context.Context, userID int64) (*dom
|
|||
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) {
|
||||
return s.store.GetUserSubscriptionHistory(ctx, userID, limit, offset)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,15 @@ type UploadResult struct {
|
|||
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) {
|
||||
video, err := s.client.GetVideo(ctx, videoID)
|
||||
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 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{
|
||||
VimeoID: videoID,
|
||||
URI: video.URI,
|
||||
|
|
@ -66,7 +87,7 @@ func (s *Service) GetVideoInfo(ctx context.Context, videoID string) (*VideoInfo,
|
|||
|
||||
if video.PlayerEmbedURL != "" {
|
||||
info.EmbedURL = video.PlayerEmbedURL
|
||||
} else {
|
||||
} else if videoID != "" {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import (
|
|||
"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 {
|
||||
if t, ok := v.(time.Time); ok {
|
||||
return t
|
||||
|
|
@ -17,7 +22,7 @@ func toTime(v interface{}) time.Time {
|
|||
|
||||
// GetAnalyticsDashboard godoc
|
||||
// @Summary Analytics dashboard
|
||||
// @Description Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range.
|
||||
// @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
|
||||
// @Produce json
|
||||
// @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")
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch course analytics")
|
||||
|
|
@ -165,13 +181,8 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
|
|||
DateFilter: filter,
|
||||
Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs),
|
||||
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
|
||||
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30),
|
||||
Courses: domain.AnalyticsCoursesSection{
|
||||
TotalCategories: courseCounts.TotalCategories,
|
||||
TotalCourses: courseCounts.TotalCourses,
|
||||
TotalSubCourses: courseCounts.TotalSubCourses,
|
||||
TotalVideos: courseCounts.TotalVideos,
|
||||
},
|
||||
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear),
|
||||
Courses: mapCoursesSection(courseCounts),
|
||||
Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType),
|
||||
Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType),
|
||||
Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType),
|
||||
|
|
@ -181,6 +192,34 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
|
|||
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(
|
||||
summary dbgen.AnalyticsUsersSummaryRow,
|
||||
byRole []dbgen.AnalyticsUsersByRoleRow,
|
||||
|
|
@ -263,6 +302,8 @@ func mapPaymentsSection(
|
|||
byStatus []dbgen.AnalyticsPaymentsByStatusRow,
|
||||
byMethod []dbgen.AnalyticsPaymentsByMethodRow,
|
||||
revenue []dbgen.AnalyticsRevenueLast30DaysRow,
|
||||
revenueMonthly []dbgen.AnalyticsRevenueMonthlyByYearRow,
|
||||
monthlyYear *int,
|
||||
) domain.AnalyticsPaymentsSection {
|
||||
statuses := make([]domain.AnalyticsLabelAmount, len(byStatus))
|
||||
for i, r := range byStatus {
|
||||
|
|
@ -276,6 +317,27 @@ func mapPaymentsSection(
|
|||
for i, r := range revenue {
|
||||
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{
|
||||
TotalRevenue: summary.TotalRevenue,
|
||||
AvgTransactionValue: summary.AvgValue,
|
||||
|
|
@ -284,6 +346,8 @@ func mapPaymentsSection(
|
|||
ByStatus: statuses,
|
||||
ByMethod: methods,
|
||||
RevenueLast30Days: timePoints,
|
||||
RevenueMonthly: monthlyPoints,
|
||||
MonthlyRevenueYear: monthlyYear,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
|
|||
|
||||
// GetAllUsers godoc
|
||||
// @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
|
||||
// @Accept 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())
|
||||
}
|
||||
|
||||
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
|
||||
// result := make([]domain.UserProfileResponse, len(users))
|
||||
// for i, u := range users {
|
||||
|
|
@ -538,6 +551,11 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
|||
if !u.BirthDay.IsZero() {
|
||||
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{
|
||||
ID: u.ID,
|
||||
FirstName: u.FirstName,
|
||||
|
|
@ -567,6 +585,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
|||
PreferredLanguage: u.PreferredLanguage,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
ActiveSubscription: activeSub,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handlers
|
|||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/pkgs/vimeo"
|
||||
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
|
|
@ -62,6 +63,124 @@ type VimeoEmbedResponse struct {
|
|||
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
|
||||
// @Summary Get video information from Vimeo
|
||||
// @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{
|
||||
Message: "Video retrieved successfully",
|
||||
Data: 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,
|
||||
},
|
||||
Message: "Video retrieved successfully",
|
||||
Data: vimeoVideoInfoToResponse(info),
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
|
|
@ -413,21 +518,7 @@ func (h *Handler) GetSampleVideo(c *fiber.Ctx) error {
|
|||
return c.JSON(domain.Response{
|
||||
Message: "Sample video retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
"video": 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,
|
||||
},
|
||||
"video": vimeoVideoInfoToResponse(info),
|
||||
"iframe": iframe,
|
||||
},
|
||||
Success: true,
|
||||
|
|
|
|||
|
|
@ -357,6 +357,7 @@ func (a *App) initAppRoutes() {
|
|||
|
||||
// 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/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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user