From 5dc990db894d6884fedc7819a7d0e6d358aa79dc Mon Sep 17 00:00:00 2001 From: PThorpe92 Date: Fri, 29 Nov 2024 21:31:27 -0500 Subject: [PATCH] feat: add layer 0 operational insights page w/ charts for user data --- .../00024_add_login_count_table.sql | 28 +++ backend/src/database/users.go | 105 ++++++++++ backend/src/handlers/dashboard.go | 108 ++++++++++ backend/src/handlers/left_menu_handler.go | 1 - backend/src/handlers/login_flow.go | 5 + backend/src/handlers/server.go | 15 +- backend/src/models/facilities.go | 5 +- backend/src/models/logins.go | 23 ++ backend/src/models/users.go | 1 + config/kratos/kratos.yml | 2 +- frontend/src/Components/Navbar.tsx | 9 +- frontend/src/Components/StudentMetrics.tsx | 196 ++++++++++++++++++ frontend/src/Pages/OperationalInsights.tsx | 10 + frontend/src/app.tsx | 11 + frontend/src/common.ts | 18 ++ provider-middleware/handlers.go | 2 +- provider-middleware/video_dl.go | 13 +- 17 files changed, 533 insertions(+), 19 deletions(-) create mode 100644 backend/migrations/00024_add_login_count_table.sql create mode 100644 backend/src/models/logins.go create mode 100644 frontend/src/Components/StudentMetrics.tsx create mode 100644 frontend/src/Pages/OperationalInsights.tsx diff --git a/backend/migrations/00024_add_login_count_table.sql b/backend/migrations/00024_add_login_count_table.sql new file mode 100644 index 00000000..511f14b8 --- /dev/null +++ b/backend/migrations/00024_add_login_count_table.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE public.login_metrics ( + user_id INTEGER NOT NULL PRIMARY KEY, + total BIGINT NOT NULL DEFAULT 1, + last_login timestamp with time zone DEFAULT now(), + FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX user_login_metrics_user_id_idx ON public.login_metrics(user_id); +CREATE INDEX user_login_metrics_login_count_idx ON public.login_metrics(total); +CREATE INDEX user_login_metrics_last_login_idx ON public.login_metrics(last_login); + +CREATE TABLE public.login_activity ( + time_interval TIMESTAMP NOT NULL, + facility_id INT, + total_logins BIGINT DEFAULT 1, + FOREIGN KEY (facility_id) REFERENCES public.facilities(id) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY (time_interval, facility_id) +); +CREATE INDEX login_activity_time_interval_idx ON public.login_activity(time_interval); +CREATE INDEX login_activity_facility_id_idx ON public.login_activity(facility_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE public.login_metrics CASCADE; +DROP TABLE public.login_activity CASCADE; +-- +goose StatementEnd diff --git a/backend/src/database/users.go b/backend/src/database/users.go index a8bd81e4..6870bf2f 100644 --- a/backend/src/database/users.go +++ b/backend/src/database/users.go @@ -4,6 +4,7 @@ import ( "UnlockEdv2/src/models" "errors" "strings" + "time" log "github.com/sirupsen/logrus" "gorm.io/gorm" @@ -185,3 +186,107 @@ func (db *DB) ToggleProgramFavorite(user_id uint, id uint) (bool, error) { } return favRemoved, nil } + +func (db *DB) IncrementUserLogin(username string) error { + log.Printf("Incrementing login count for %s", username) + user, err := db.GetUserByUsername(username) + if err != nil { + log.Errorf("Error getting user by username: %v", err) + return newGetRecordsDBError(err, "users") + } + if err := db.Debug().Exec( + `INSERT INTO login_metrics (user_id, total, last_login) + VALUES (?, 1, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) DO UPDATE + SET total = login_metrics.total + 1, last_login = CURRENT_TIMESTAMP`, + user.ID).Error; err != nil { + log.Errorf("Error incrementing login count: %v", err) + return newUpdateDBError(err, "login_metrics") + } + now := time.Now() + rounded := now.Truncate(time.Hour) + + if err := db.Debug().Exec( + `INSERT INTO login_activity (time_interval, facility_id, total_logins) + VALUES (?, ?, ?) + ON CONFLICT (time_interval, facility_id) + DO UPDATE SET total_logins = login_activity.total_logins + 1`, + rounded, user.FacilityID, 1).Error; err != nil { + log.Errorf("Error incrementing login activity: %v", err) + return newUpdateDBError(err, "login_activity") + } + + log.Printf("FINISHED Incremented login count for %s", username) + return nil +} + +func (db *DB) GetNumberOfActiveUsersForTimePeriod(active bool, days int, facilityId *uint) (int64, error) { + var count int64 + daysAgo := time.Now().AddDate(0, 0, -days) + join := "JOIN login_metrics on users.id = login_metrics.user_id AND login_metrics.last_login " + if active { + join += "> ?" + } else { + join += "< ?" + } + tx := db.Model(&models.User{}).Joins(join, daysAgo).Where("role = 'student'") + if facilityId != nil { + tx = tx.Where("facility_id = ?", *facilityId) + } + if err := tx.Count(&count).Error; err != nil { + return 0, newGetRecordsDBError(err, "users") + } + return count, nil +} + +func (db *DB) NewUsersInTimePeriod(days int, facilityId *uint) (int64, error) { + var count int64 + daysAgo := time.Now().AddDate(0, 0, -days) + tx := db.Model(&models.User{}).Where("created_at > ?", daysAgo) + if facilityId != nil { + tx = tx.Where("facility_id = ?", *facilityId) + } + if err := tx.Count(&count).Error; err != nil { + return 0, newGetRecordsDBError(err, "users") + } + return count, nil +} + +func (db *DB) GetTotalLogins(days int, facilityId *uint) (int64, error) { + var total int64 + daysAgo := time.Now().AddDate(0, 0, -days) + tx := db.Model(&models.LoginMetrics{}).Select("SUM(total)").Where("last_login > ?", daysAgo) + if facilityId != nil { + tx = tx.Joins("JOIN users on login_metrics.user_id = users.id").Where("users.facility_id = ?", *facilityId) + } + if err := tx.Scan(&total).Error; err != nil { + return 0, newGetRecordsDBError(err, "login_counts") + } + return total, nil +} + +func (db *DB) GetTotalUsers(facilityId *uint) (int64, error) { + var total int64 + tx := db.Model(&models.User{}).Where("role = 'student'") + if facilityId != nil { + tx = tx.Where("facility_id = ?", *facilityId) + } + if err := tx.Count(&total).Error; err != nil { + return 0, newGetRecordsDBError(err, "users") + } + return total, nil +} + +func (db *DB) GetLoginActivity(days int, facilityID *uint) ([]models.LoginActivity, error) { + acitvity := make([]models.LoginActivity, 0, 3) + daysAgo := time.Now().AddDate(0, 0, -days) + if err := db.Raw(`SELECT time_interval, total_logins + FROM login_activity + WHERE time_interval >= ? + ORDER BY total_logins DESC + LIMIT 3;`, daysAgo). + Scan(&acitvity).Error; err != nil { + return nil, newGetRecordsDBError(err, "login_activity") + } + return acitvity, nil +} diff --git a/backend/src/handlers/dashboard.go b/backend/src/handlers/dashboard.go index f92914fd..ca06bb92 100644 --- a/backend/src/handlers/dashboard.go +++ b/backend/src/handlers/dashboard.go @@ -2,14 +2,20 @@ package handlers import ( "UnlockEdv2/src/models" + "encoding/json" + "errors" + "fmt" "net/http" "strconv" "strings" + + "github.com/nats-io/nats.go" ) func (srv *Server) registerDashboardRoutes() []routeDef { axx := models.Feature(models.ProviderAccess) return []routeDef{ + {"GET /api/login-metrics", srv.handleLoginMetrics, true, models.Feature()}, {"GET /api/users/{id}/student-dashboard", srv.handleStudentDashboard, false, models.Feature()}, {"GET /api/users/{id}/admin-dashboard", srv.handleAdminDashboard, true, models.Feature()}, {"GET /api/users/{id}/catalogue", srv.handleUserCatalogue, false, axx}, @@ -42,6 +48,108 @@ func (srv *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request, return writeJsonResponse(w, http.StatusOK, adminDashboard) } +func (srv *Server) handleLoginMetrics(w http.ResponseWriter, r *http.Request, log sLog) error { + facility := r.URL.Query().Get("facility") + claims := r.Context().Value(ClaimsKey).(*Claims) + var facilityId *uint + facilityName := "" + clearCache := r.URL.Query().Get("reset") == "true" + switch facility { + case "all": + facilityId = nil + facilityName = "All" + case "": + facilityId = &claims.FacilityID + facility = strconv.Itoa(int(claims.FacilityID)) + facilityName = claims.FacilityName + default: + facilityIdInt, err := strconv.Atoi(facility) + if err != nil { + return newInvalidIdServiceError(err, "facility") + } + ref := uint(facilityIdInt) + facilityId = &ref + facility, err := srv.Db.GetFacilityByID(facilityIdInt) + if err != nil { + return newDatabaseServiceError(err) + } + facilityName = facility.Name + } + var days int + daysQ := r.URL.Query().Get("days") + if daysQ == "" { + days = 30 + } else { + numDays, err := strconv.Atoi(daysQ) + if err != nil { + return newInvalidIdServiceError(err, "days") + } + days = numDays + } + key := fmt.Sprintf("%s-%d", facility, days) + cached, err := srv.buckets[LoginMetrics].Get(key) + if err != nil && errors.Is(err, nats.ErrKeyNotFound) || clearCache { + acitveUsers, err := srv.Db.GetNumberOfActiveUsersForTimePeriod(true, days, facilityId) + if err != nil { + return newDatabaseServiceError(err) + } + totalLogins, err := srv.Db.GetTotalLogins(days, facilityId) + if err != nil { + return newDatabaseServiceError(err) + } + totalUsers, err := srv.Db.GetTotalUsers(facilityId) + if err != nil { + return newDatabaseServiceError(err) + } + newAdded, err := srv.Db.NewUsersInTimePeriod(days, facilityId) + if err != nil { + return newDatabaseServiceError(err) + } + loginTimes, err := srv.Db.GetLoginActivity(days, facilityId) + if err != nil { + return newDatabaseServiceError(err) + } + if totalUsers == 0 { + totalUsers = 1 + } + activityMetrics := struct { + ActiveUsers int64 `json:"active_users"` + TotalLogins int64 `json:"total_logins"` + LoginsPerDay int64 `json:"logins_per_day"` + PercentActive int64 `json:"percent_active"` + PercentInactive int64 `json:"percent_inactive"` + TotalUsers int64 `json:"total_users"` + Facility string `json:"facility,omitempty"` + NewStudentsAdded int64 `json:"new_residents_added"` + PeakLoginTimes []models.LoginActivity `json:"peak_login_times"` + }{ + ActiveUsers: acitveUsers, + TotalLogins: totalLogins, + LoginsPerDay: totalLogins * int64(days), + PercentActive: acitveUsers / totalUsers * 100, + PercentInactive: 100 - (acitveUsers / totalUsers * 100), + TotalUsers: totalUsers, + Facility: facilityName, + NewStudentsAdded: newAdded, + PeakLoginTimes: loginTimes, + } + jsonB, err := json.Marshal(models.DefaultResource(activityMetrics)) + if err != nil { + return newMarshallingBodyServiceError(err) + } + _, err = srv.buckets[LoginMetrics].Put(key, jsonB) + if err != nil { + return newInternalServerServiceError(err, "Error caching login metrics") + } + _, err = w.Write(jsonB) + return err + } else if err != nil { + return newInternalServerServiceError(err, "Error retrieving login metrics") + } + _, err = w.Write(cached.Value()) + return err +} + /** * GET: /api/users/{id}/catalogue * @Query Params: diff --git a/backend/src/handlers/left_menu_handler.go b/backend/src/handlers/left_menu_handler.go index 24cbcc78..b852fe43 100644 --- a/backend/src/handlers/left_menu_handler.go +++ b/backend/src/handlers/left_menu_handler.go @@ -16,7 +16,6 @@ func (srv *Server) registerLeftMenuRoutes() []routeDef { } func (srv *Server) handleGetLeftMenu(w http.ResponseWriter, r *http.Request, log sLog) error { - log.info("GET: /api/left-menu") var limit int limit, err := strconv.Atoi(r.URL.Query().Get("limit")) if err != nil { diff --git a/backend/src/handlers/login_flow.go b/backend/src/handlers/login_flow.go index af923dd4..472fdca2 100644 --- a/backend/src/handlers/login_flow.go +++ b/backend/src/handlers/login_flow.go @@ -77,6 +77,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request, log sLog) e if err != nil { return NewServiceError(err, resp.StatusCode, "Invalid login") } + err = s.Db.IncrementUserLogin(form.Username) + if err != nil { + log.error("Error incrementing user login count", err) + return newDatabaseServiceError(err) + } return writeJsonResponse(w, http.StatusOK, redirect) } diff --git a/backend/src/handlers/server.go b/backend/src/handlers/server.go index 2038723e..e1f97e0b 100644 --- a/backend/src/handlers/server.go +++ b/backend/src/handlers/server.go @@ -13,6 +13,7 @@ import ( "slices" "strconv" "strings" + "time" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -210,6 +211,7 @@ func oryConfig() *ory.Configuration { const ( CachedUsers string = "cache_users" LibraryPaths string = "library_paths" + LoginMetrics string = "login_metrics" ) func (srv *Server) setupNatsKvBuckets() error { @@ -219,12 +221,19 @@ func (srv *Server) setupNatsKvBuckets() error { return err } buckets := map[string]nats.KeyValue{} - for _, bucket := range []string{CachedUsers, LibraryPaths} { + for _, bucket := range []string{CachedUsers, LibraryPaths, LoginMetrics} { kv, err := js.KeyValue(bucket) if err == nats.ErrBucketNotFound { - kv, err = js.CreateKeyValue(&nats.KeyValueConfig{ + cfg := &nats.KeyValueConfig{ Bucket: bucket, - }) + } + switch bucket { + case CachedUsers: + cfg.TTL = time.Hour * 1 + case LoginMetrics: + cfg.TTL = time.Hour * 24 + } + kv, err = js.CreateKeyValue(cfg) if err != nil { log.Errorf("Error creating JetStream KV store: %v", err) return err diff --git a/backend/src/models/facilities.go b/backend/src/models/facilities.go index e603cbea..fcb4a113 100644 --- a/backend/src/models/facilities.go +++ b/backend/src/models/facilities.go @@ -5,8 +5,9 @@ type Facility struct { Name string `gorm:"size:255;not null" json:"name"` Timezone string `gorm:"size:255;not null" json:"timezone" validate:"timezone"` - Users []User `gorm:"foreignKey:FacilityID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"-"` - Programs []Program `json:"programs" gorm:"many2many:facilities_programs;"` + Users []User `gorm:"foreignKey:FacilityID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"-"` + Programs []Program `json:"programs" gorm:"many2many:facilities_programs;"` + LoginActivity []LoginActivity `json:"login_activity" gorm:"foreignKey:FacilityID;constraint:OnDelete CASCADE"` } func (Facility) TableName() string { diff --git a/backend/src/models/logins.go b/backend/src/models/logins.go new file mode 100644 index 00000000..26fdef23 --- /dev/null +++ b/backend/src/models/logins.go @@ -0,0 +1,23 @@ +package models + +import "time" + +type LoginMetrics struct { + UserID uint `json:"user_id" gorm:"primaryKey"` + Total int64 `json:"total" gorm:"default:1"` + LastLogin time.Time `json:"last_login" gorm:"default:CURRENT_TIMESTAMP"` + + User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"` +} + +func (LoginMetrics) TableName() string { return "login_metrics" } + +type LoginActivity struct { + TimeInterval time.Time `json:"time_interval" gorm:"primaryKey"` + FacilityID uint `json:"facility_id" gorm:"primaryKey"` + TotalLogins int64 `json:"total_logins" gorm:"default:1"` + + Facility *Facility `json:"facility,omitempty" gorm:"foreignKey:FacilityID;constraint:OnDelete CASCADE"` +} + +func (LoginActivity) TableName() string { return "login_activity" } diff --git a/backend/src/models/users.go b/backend/src/models/users.go index 57346d97..e40f4def 100644 --- a/backend/src/models/users.go +++ b/backend/src/models/users.go @@ -45,6 +45,7 @@ type User struct { FavoritePrograms []ProgramFavorite `json:"favorite_programs,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"` Facility *Facility `json:"facility,omitempty" gorm:"foreignKey:FacilityID;constraint:OnDelete SET NULL"` UserRole *Role `json:"-" gorm:"foreignKey:Role;constraint:OnDelete SET NULL"` + LoginCount *LoginMetrics `json:"login_count,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"` } type ImportUser struct { diff --git a/config/kratos/kratos.yml b/config/kratos/kratos.yml index 97195f8b..e379f3e5 100644 --- a/config/kratos/kratos.yml +++ b/config/kratos/kratos.yml @@ -127,6 +127,6 @@ courier: from_address: no-reply@ory.kratos.sh local_name: localhost -dev: true +dev: false feature_flags: use_continue_with_transitions: true diff --git a/frontend/src/Components/Navbar.tsx b/frontend/src/Components/Navbar.tsx index 2225287e..0a99d734 100644 --- a/frontend/src/Components/Navbar.tsx +++ b/frontend/src/Components/Navbar.tsx @@ -16,7 +16,8 @@ import { DocumentTextIcon, FolderOpenIcon, CloudIcon, - RectangleStackIcon + RectangleStackIcon, + CogIcon } from '@heroicons/react/24/solid'; import { handleLogout, hasFeature, isAdministrator, useAuth } from '@/useAuth'; import ULIComponent from './ULIComponent'; @@ -105,6 +106,12 @@ export default function Navbar({ )} +
  • + + + Operational Insights + +
  • {hasFeature( user, FeatureAccess.ProviderAccess diff --git a/frontend/src/Components/StudentMetrics.tsx b/frontend/src/Components/StudentMetrics.tsx new file mode 100644 index 00000000..7de0a287 --- /dev/null +++ b/frontend/src/Components/StudentMetrics.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { AxiosError } from 'axios'; +import API from '@/api/api'; +import { Facility, LoginMetrics, ServerResponseOne } from '@/common'; +import { + BarChart, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + Legend, + Bar +} from 'recharts'; +import PrimaryButton from './PrimaryButton'; + +const OperationalInsights = () => { + const [facilities, setFacilities] = useState(); + const [facility, setFacility] = useState('all'); + const [days, setDays] = useState(7); + const [resetCache, setResetCache] = useState(false); + + const { data, error, isLoading, mutate } = useSWR< + ServerResponseOne, + AxiosError + >( + `/api/login-metrics?facility=${facility}&days=${days}&reset=${resetCache}` + ); + + useEffect(() => { + void mutate(); + }, [facility, days, resetCache]); + + useEffect(() => { + const fetchFacilities = async () => { + const response = await API.get('facilities'); + setFacilities(response.data as Facility[]); + }; + void fetchFacilities(); + }, []); + + const metrics = data?.data; + + return ( +
    + {error &&
    Error loading data
    } + {!data || (isLoading &&
    Loading...
    )} + {data && metrics && ( + <> +
    + setResetCache(!resetCache)} + > + Refresh Data + +
    +
    +
    Total Users
    +
    + {metrics.total_users} +
    +
    +
    +
    Active Users
    +
    + {metrics.active_users} +
    +
    + {( + (metrics.active_users / + metrics.total_users) * + 100 + ).toFixed(2)} + % of total users +
    +
    +
    +
    Inactive Users
    +
    + {metrics.total_users - metrics.active_users} +
    +
    +
    +
    + Engagement Rate +
    +
    + {( + (metrics.active_users / + metrics.total_users) * + 100 + ).toFixed(2)} + % +
    +
    +
    +
    Total Logins
    +
    + {metrics.total_logins} +
    +
    +
    +
    Logins per Day
    +
    + {metrics.logins_per_day} +
    +
    +
    +
    + New Residents Added +
    +
    + {metrics.new_residents_added} +
    +
    +
    +
    + + {/* Peak Login Times Chart */} +
    +

    + Peak Login Times +

    +
    + + + + new Date(tick).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }) + } + /> + + + + + +
    +
    + + {/* Filters */} +
    +
    + + +
    +
    + + +
    +
    + + )} +
    + ); +}; + +export default OperationalInsights; diff --git a/frontend/src/Pages/OperationalInsights.tsx b/frontend/src/Pages/OperationalInsights.tsx new file mode 100644 index 00000000..91f97fb5 --- /dev/null +++ b/frontend/src/Pages/OperationalInsights.tsx @@ -0,0 +1,10 @@ +import OperationalInsights from '@/Components/StudentMetrics'; + +export default function OperationalInsightsPage() { + return ( +
    +

    Operational Insights

    + +
    + ); +} diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index fd4a7325..494a26a2 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -55,6 +55,7 @@ import OpenContentManagement from './Pages/OpenContentManagement.tsx'; import { FeatureAccess, INIT_KRATOS_LOGIN_FLOW } from './common.ts'; import FavoritesPage from './Pages/Favorites.tsx'; import OpenContentLevelDashboard from './Pages/OpenContentLevelDashboard.tsx'; +import OperationalInsightsPage from './Pages/OperationalInsights.tsx'; const WithAuth: React.FC = () => { return ( @@ -314,9 +315,19 @@ const router = createBrowserRouter([ element: , children: [ { + id: 'admin', element: , loader: getFacilities, children: [ + { + path: 'operational-insights', + element: , + errorElement: , + handle: { + title: 'Operational Insights', + path: ['operational-insights'] + } + }, { path: 'student-management', element: , diff --git a/frontend/src/common.ts b/frontend/src/common.ts index 9c449a45..1274ff70 100644 --- a/frontend/src/common.ts +++ b/frontend/src/common.ts @@ -344,6 +344,24 @@ export interface ResourceCategory { rank: number; } +export interface LoginMetrics { + active_users: number; + total_logins: number; + logins_per_day: number; + percent_active: number; + percent_inactive: number; + total_users: number; + facility: string; + new_residents_added: number; + peak_login_times: LoginActivity[]; +} + +export interface LoginActivity { + time_interval: string; + total_logins: number; + facility_id: number; +} + export type ResourceLink = Record; export type EditableResourceCollection = ResourceCategory & { diff --git a/provider-middleware/handlers.go b/provider-middleware/handlers.go index 6f5e872b..0510b488 100644 --- a/provider-middleware/handlers.go +++ b/provider-middleware/handlers.go @@ -38,7 +38,7 @@ func (sh *ServiceHandler) initSubscription() error { {models.SyncVideoMetadataJob.PubName(), sh.handleSyncVideoMetadata}, } for _, sub := range subscriptions { - _, err := sh.nats.Subscribe(sub.topic, func(msg *nats.Msg) { + _, err := sh.nats.QueueSubscribe(sub.topic, "middleware", func(msg *nats.Msg) { go sub.fn(sh.ctx, msg) }) if err != nil { diff --git a/provider-middleware/video_dl.go b/provider-middleware/video_dl.go index a7156b05..19c7b2c2 100644 --- a/provider-middleware/video_dl.go +++ b/provider-middleware/video_dl.go @@ -165,7 +165,6 @@ func (yt *VideoService) downloadAndHostThumbnail(yt_id, url string) (string, err } req.Header.Set("User-Agent", "Mozilla/5.0") req.Header.Set("Accept", "image/*") - resp, err := yt.Client.Do(req) if err != nil { logger().Errorf("error fetching thumbnail image from URL: %v", err) @@ -176,17 +175,15 @@ func (yt *VideoService) downloadAndHostThumbnail(yt_id, url string) (string, err logger().Errorf("failed to fetch thumbnail image: received %v response", resp.Status) return "", fmt.Errorf("failed to fetch thumbnail image: %v", resp.Status) } - imgData, err := io.ReadAll(resp.Body) if err != nil { logger().Errorf("error reading thumbnail image: %v", err) return "", err } - body := &bytes.Buffer{} writer := multipart.NewWriter(body) - filename := yt_id + ".jpg" + filename := yt_id + ".jpg" part, err := writer.CreateFormFile("file", filename) if err != nil { return "", err @@ -195,8 +192,7 @@ func (yt *VideoService) downloadAndHostThumbnail(yt_id, url string) (string, err if err != nil { return "", err } - fields := map[string]string{"filename": filename, "size": fmt.Sprintf("%d", len(imgData)), "type": "image/jpg"} - for key, value := range fields { + for key, value := range map[string]string{"filename": filename, "size": fmt.Sprintf("%d", len(imgData)), "type": "image/jpg"} { err = writer.WriteField(key, value) if err != nil { return "", err @@ -207,15 +203,12 @@ func (yt *VideoService) downloadAndHostThumbnail(yt_id, url string) (string, err logger().Errorf("error closing writer: %v", err) return "", err } - - uploadURL := os.Getenv("APP_URL") + "/upload" - req, err = http.NewRequest(http.MethodPost, uploadURL, body) + req, err = http.NewRequest(http.MethodPost, os.Getenv("APP_URL")+"/upload", body) if err != nil { logger().Errorf("error creating upload request: %v", err) return "", err } req.Header.Set("Content-Type", writer.FormDataContentType()) - uploadResp, err := yt.Client.Do(req) if err != nil { logger().Errorf("error sending upload request: %v", err)