diff --git a/docs/docs.go b/docs/docs.go index b8e21bc..cb863d9 100644 --- a/docs/docs.go +++ b/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.", + "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", @@ -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,375 @@ 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": { + "total_categories": { + "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.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.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.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" + } + }, + "revenue_last_30_days": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsRevenueTimePoint" + } + }, + "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": [ @@ -11198,6 +11724,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": [ diff --git a/docs/swagger.json b/docs/swagger.json index 4184b37..b38b2ea 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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.", + "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", @@ -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,375 @@ "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": { + "total_categories": { + "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.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.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.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" + } + }, + "revenue_last_30_days": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsRevenueTimePoint" + } + }, + "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": [ @@ -11190,6 +11716,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": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ae7b2f7..7c6ee0f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -17,6 +17,247 @@ 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: + total_categories: + 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.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.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.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 + revenue_last_30_days: + items: + $ref: '#/definitions/domain.AnalyticsRevenueTimePoint' + 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: @@ -1190,6 +1431,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 +2862,45 @@ 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.' + 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 @@ -7824,6 +8123,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: diff --git a/internal/pkgs/vimeo/client.go b/internal/pkgs/vimeo/client.go index 8926a16..a718ffc 100644 --- a/internal/pkgs/vimeo/client.go +++ b/internal/pkgs/vimeo/client.go @@ -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 { diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 646291a..2d13c01 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -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) diff --git a/internal/services/vimeo/service.go b/internal/services/vimeo/service.go index dc322c7..47dfbc3 100644 --- a/internal/services/vimeo/service.go +++ b/internal/services/vimeo/service.go @@ -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) { diff --git a/internal/web_server/handlers/vimeo.go b/internal/web_server/handlers/vimeo.go index 08351ab..1beab71 100644 --- a/internal/web_server/handlers/vimeo.go +++ b/internal/web_server/handlers/vimeo.go @@ -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, diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 0f8d0d5..1fc6a53 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)