diff --git a/api/config/main.go b/api/config/main.go index 379d643e..b2a2df54 100644 --- a/api/config/main.go +++ b/api/config/main.go @@ -28,6 +28,7 @@ type AppConfig struct { Secret string `envconfig:"JWT_SECRET"` WebUrl string `envconfig:"WEB_URL"` JWTIssuer string `envconfig:"ISSUER"` + FilePath string `envconfig:"FILE_PATH"` RedisClient RedisClientConfig DB DBConfig Kratos KratosConfig diff --git a/api/constants/constant.go b/api/constants/constant.go index 6916379e..cc845c70 100644 --- a/api/constants/constant.go +++ b/api/constants/constant.go @@ -275,6 +275,9 @@ const ( const ( SingleAnswerString = "single answer" SurveyString = "survey" + SingleString = "single" + AllString = "all" + SingleAnsField = "single answer" SingleAnswer = 1 Survey = 2 @@ -296,3 +299,23 @@ const ( ChannelUserDisconnect = "user_disconnect" ChannelSetAnswer = "set_answer" ) + +// Analysis Download Queries +const ( + DownloadFileName = "quiz_analysis" + QuestionType = "question_type" + ContentType = "Content-Type" + CsvAcceptHeader = "text/csv" +) + +const ( + PageLimit = "user_limit" + PageOffset = "starting_at" + SortOrder = "order_by" + AscOrder = "asc" + DescOrder = "desc" + DefaultUserLimit = "10" + DefaultUserOffset = "0" + IsTop10Users = "top_10" + Top10UserTrueValue = "true" +) diff --git a/api/controllers/api/v1/download_participants_controller.go b/api/controllers/api/v1/download_participants_controller.go new file mode 100644 index 00000000..c708326c --- /dev/null +++ b/api/controllers/api/v1/download_participants_controller.go @@ -0,0 +1,83 @@ +package v1 + +import ( + "net/http" + "strings" + + "github.com/Improwised/jovvix/api/config" + "github.com/Improwised/jovvix/api/constants" + "github.com/Improwised/jovvix/api/models" + "github.com/Improwised/jovvix/api/services" + "github.com/Improwised/jovvix/api/utils" + "github.com/doug-martin/goqu/v9" + fiber "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +type DownloadParticipantsController struct { + QuizModel *models.QuizModel + FinalScoreBoardAdminModel models.FinalScoreBoardAdminModel + Config *config.AppConfig +} + +func InitDownloadParticipantsController(db *goqu.Database, logger *zap.Logger, appConfig *config.AppConfig) (*DownloadParticipantsController, error) { + quizModel := models.InitQuizModel(db) + + finalScoreBoardAdminModel, err := models.InitFinalScoreBoardAdminModel(db) + if err != nil { + return nil, err + } + + return &DownloadParticipantsController{ + QuizModel: quizModel, + FinalScoreBoardAdminModel: finalScoreBoardAdminModel, + Config: appConfig, + }, nil +} + +func (dcp *DownloadParticipantsController) DownloadParticipantsReport(c *fiber.Ctx) error { + activeQuizId := c.Params(constants.ActiveQuizId) + isTop10Users := strings.ToLower(c.Query(constants.IsTop10Users)) + userOrder := strings.ToLower(c.Query(constants.SortOrder)) + + order := constants.DescOrder + + quizAnalysis, err := dcp.QuizModel.GetQuizAnalysis(activeQuizId) + if err != nil { + return utils.JSONError(c, http.StatusInternalServerError, err.Error()) + } + + totalUsers, err := dcp.FinalScoreBoardAdminModel.TotoalUsers(activeQuizId) + if err != nil { + return err + } + + limit := totalUsers + + if isTop10Users == constants.Top10UserTrueValue { + limit = 10 + } + + if userOrder == constants.AscOrder { + order = constants.AscOrder + } + + rankData, err := dcp.FinalScoreBoardAdminModel.GetScoreForAdmin(activeQuizId, limit, 0, order) + if err != nil { + return err + } + + input := utils.InputParticipants{ + QuizAnalysis: quizAnalysis, + FinalScoreBoardAdmin: rankData, + } + + filePath, err := services.GenerateCsv(dcp.Config.FilePath, activeQuizId, input, func(ip utils.InputParticipants) [][]string { + return utils.GetParticipantsData(ip) + }) + + if err != nil { + return err + } + return utils.CsvFileResponse(c, filePath, activeQuizId+".csv") +} diff --git a/api/controllers/api/v1/final_scoreboard_admin_controller.go b/api/controllers/api/v1/final_scoreboard_admin_controller.go index da38c443..389dec43 100644 --- a/api/controllers/api/v1/final_scoreboard_admin_controller.go +++ b/api/controllers/api/v1/final_scoreboard_admin_controller.go @@ -3,6 +3,8 @@ package v1 import ( "errors" "net/http" + "strconv" + "strings" "github.com/Improwised/jovvix/api/constants" "github.com/Improwised/jovvix/api/models" @@ -45,19 +47,51 @@ func NewFinalScoreBoardAdminController(goqu *goqu.Database, logger *zap.Logger) // 400: GenericResFailNotFound // 500: GenericResError func (fc *FinalScoreBoardAdminController) GetScoreForAdmin(ctx *fiber.Ctx) error { - + var limit, offset int + var err error var activeQuizId = ctx.Query(constants.ActiveQuizId) + userLimit := ctx.Query(constants.PageLimit) + userOffset := ctx.Query(constants.PageOffset) + userSortOrder := strings.ToLower(ctx.Query(constants.SortOrder)) + + if userLimit == "" { + userLimit = constants.DefaultUserLimit + } + + if userOffset == "" { + userOffset = constants.DefaultUserOffset + } + + limit, err = strconv.Atoi(userLimit) + if err != nil { + return err + } + + offset, err = strconv.Atoi(userOffset) + if err != nil { + return err + } + + if userSortOrder != constants.AscOrder && userSortOrder != constants.DescOrder { + userSortOrder = constants.DescOrder + } + if !(activeQuizId != "" && len(activeQuizId) == 36) { fc.logger.Debug("active quiz id is not valid - either empty string or it is not 36 characters long") return utils.JSONFail(ctx, http.StatusBadRequest, errors.New("user play quiz should be valid string").Error()) } - finalScoreBoardData, err := fc.finalScoreBoardAdminModel.GetScoreForAdmin(activeQuizId) + finalScoreBoardData, err := fc.finalScoreBoardAdminModel.GetScoreForAdmin(activeQuizId, limit, offset, userSortOrder) if err != nil { fc.logger.Error("Error while getting final scoreboard for admin", zap.Error(err)) return utils.JSONFail(ctx, http.StatusInternalServerError, errors.New("internal server error").Error()) } + totalUsers, err := fc.finalScoreBoardAdminModel.TotoalUsers(activeQuizId) - return utils.JSONSuccess(ctx, http.StatusOK, finalScoreBoardData) + if err != nil { + fc.logger.Error("Error while getting total user", zap.Error(err)) + return utils.JSONFail(ctx, http.StatusInternalServerError, errors.New("internal server error").Error()) + } + return utils.JSONSuccessWithPagination(ctx, http.StatusOK, finalScoreBoardData, totalUsers) } diff --git a/api/controllers/api/v1/quiz_controller.go b/api/controllers/api/v1/quiz_controller.go index 318be145..08350915 100644 --- a/api/controllers/api/v1/quiz_controller.go +++ b/api/controllers/api/v1/quiz_controller.go @@ -288,3 +288,30 @@ func (ctrl *QuizController) DeleteQuizById(c *fiber.Ctx) error { ctrl.logger.Debug("QuizController.DeleteQuizById success", zap.Any(constants.QuizId, quizId)) return utils.JSONSuccess(c, http.StatusOK, "success") } + +func (qc *QuizController) DownloadReport(c *fiber.Ctx) error { + questionType := c.Query(constants.QuestionType) + activeQuizId := c.Params(constants.ActiveQuizId) + contentType := c.Get(constants.ContentType) + + quizAnalysis, err := qc.quizModel.GetQuizAnalysis(activeQuizId) + + if err != nil { + qc.logger.Error("error while get quiz analysis", zap.Error(err)) + return utils.JSONError(c, http.StatusInternalServerError, err.Error()) + } + + if contentType == constants.CsvAcceptHeader { + + filePath, err := services.GenerateCsv(qc.appConfig.FilePath, activeQuizId, quizAnalysis, func(a []models.QuizAnalysis) [][]string { + return utils.GetQuestionsData(a, questionType) + }) + + if err != nil { + return err + } + return utils.CsvFileResponse(c, filePath, activeQuizId+".csv") + } + + return utils.JSONSuccess(c, http.StatusOK, quizAnalysis) +} diff --git a/api/models/final_scoreboard_admin.go b/api/models/final_scoreboard_admin.go index cd89b78b..b326b1ff 100644 --- a/api/models/final_scoreboard_admin.go +++ b/api/models/final_scoreboard_admin.go @@ -26,12 +26,18 @@ func InitFinalScoreBoardAdminModel(goqu *goqu.Database) (FinalScoreBoardAdminMod // GetScoreForAdmin to send final score after quiz over -func (model *FinalScoreBoardAdminModel) GetScoreForAdmin(activeQuizId string) ([]FinalScoreBoardAdmin, error) { +func (model *FinalScoreBoardAdminModel) GetScoreForAdmin(activeQuizId string, limit, offset int, sortOrder string) ([]FinalScoreBoardAdmin, error) { var finalScoreBoardData []FinalScoreBoardAdmin UserQuizResponseTable := "user_quiz_responses" UserPlayedQuizTable := "user_played_quizzes" + order := goqu.I("score").Desc() + + if sortOrder == "asc" { + order = goqu.I("score").Asc() + } + err := model.db. From(goqu.T("users")). Select( @@ -53,6 +59,9 @@ func (model *FinalScoreBoardAdminModel) GetScoreForAdmin(activeQuizId string) ([ UserPlayedQuizTable + ".active_quiz_id": activeQuizId, }). GroupBy(goqu.I("users.id")). + Order(order). + Limit(uint(limit)). + Offset(uint(offset)). ScanStructs(&finalScoreBoardData) if err != nil { @@ -61,3 +70,22 @@ func (model *FinalScoreBoardAdminModel) GetScoreForAdmin(activeQuizId string) ([ return finalScoreBoardData, nil } + +func (model *FinalScoreBoardAdminModel) TotoalUsers(activeQuizId string) (int, error) { + var totalCount int + ds := model.db.From("users"). + Join( + goqu.T("user_played_quizzes"), + goqu.On(goqu.I("user_played_quizzes.user_id").Eq(goqu.I("users.id"))), + ). + Select(goqu.COUNT("*")). + Where( + goqu.I("user_played_quizzes.active_quiz_id").Eq(activeQuizId), + ) + + _, err := ds.ScanVal(&totalCount) + if err != nil { + return 0, err + } + return totalCount, nil +} diff --git a/api/pkg/structs/csv.go b/api/pkg/structs/csv.go new file mode 100644 index 00000000..71dd00fd --- /dev/null +++ b/api/pkg/structs/csv.go @@ -0,0 +1,6 @@ +package structs + +type OptionsWithSelectedCount struct { + Option string `csv:"option"` + SelectedCount int `csv:"selected_count"` +} diff --git a/api/pkg/structs/participants.go b/api/pkg/structs/participants.go new file mode 100644 index 00000000..1064ed32 --- /dev/null +++ b/api/pkg/structs/participants.go @@ -0,0 +1,11 @@ +package structs + +type Participants struct { + UserName string + Rank int + Score int + Accuracy int + CorrectAnswers int + SurveyAnswers int + Attempted int +} diff --git a/api/routes/main.go b/api/routes/main.go index d11eeef6..dbfba41d 100644 --- a/api/routes/main.go +++ b/api/routes/main.go @@ -127,6 +127,11 @@ func Setup(app *fiber.App, goqu *goqu.Database, logger *zap.Logger, config confi return err } + err = setupDownloadPaticipantsController(v1, goqu, logger, middleware, config) + if err != nil { + return err + } + return nil } @@ -252,6 +257,7 @@ func setupQuizController(v1 fiber.Router, db *goqu.Database, logger *zap.Logger, report := admin.Group("/reports") report.Get("/list", quizController.ListQuizzesAnalysis) report.Get(fmt.Sprintf("/:%s/analysis", constants.ActiveQuizId), middleware.KratosAuthenticated, quizController.GetQuizAnalysis) + report.Get(fmt.Sprintf("/:%s/download/analysis", constants.ActiveQuizId), middleware.KratosAuthenticated, quizController.DownloadReport) return nil } @@ -349,3 +355,14 @@ func setupSharedQuizzesController(v1 fiber.Router, goqu *goqu.Database, logger * sharedQuizzesRouter.Delete(fmt.Sprintf("/:%s", constants.QuizId), middlewares.QuizPermission, middlewares.VerifyQuizShareAccess, sharedQuizzesController.DeleteUserPermissionOfQuiz) return nil } + +func setupDownloadPaticipantsController(v1 fiber.Router, goqu *goqu.Database, logger *zap.Logger, middleware middlewares.Middleware, config config.AppConfig) error { + downloadParticipantsController, err := controller.InitDownloadParticipantsController(goqu, logger, &config) + if err != nil { + return err + } + + reports := v1.Group("/admin/reports") + reports.Get(fmt.Sprintf("/:%s/download/participants", constants.ActiveQuizId), middleware.KratosAuthenticated, downloadParticipantsController.DownloadParticipantsReport) + return nil +} diff --git a/api/services/csv.go b/api/services/csv.go new file mode 100644 index 00000000..6171e970 --- /dev/null +++ b/api/services/csv.go @@ -0,0 +1,38 @@ +package services + +import ( + "encoding/csv" + "fmt" + "os" + "path/filepath" +) + +func GenerateCsv[T any](filePath, id string, analysis T, getData func(T) [][]string) (string, error) { + fullPath := filepath.Join(filePath, id+".csv") + + csvFile, err := os.Create(fullPath) + if err != nil { + return "", err + } + defer csvFile.Close() + + csvwriter := csv.NewWriter(csvFile) + defer csvwriter.Flush() + + data := getData(analysis) + if data == nil { + return "", fmt.Errorf("no data found") + } + + for _, row := range data { + if err := csvwriter.Write(row); err != nil { + return "", err + } + } + + if err = csvwriter.Error(); err != nil { + return "", err + } + + return fullPath, nil +} diff --git a/api/utils/download_csv.go b/api/utils/download_csv.go new file mode 100644 index 00000000..f9e4d246 --- /dev/null +++ b/api/utils/download_csv.go @@ -0,0 +1,269 @@ +package utils + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/Improwised/jovvix/api/constants" + "github.com/Improwised/jovvix/api/models" + "github.com/Improwised/jovvix/api/pkg/structs" +) + +type InputParticipants struct { + QuizAnalysis []models.QuizAnalysis + FinalScoreBoardAdmin []models.FinalScoreBoardAdmin +} + +type UserStatics struct { + Accuracy int + CorrectAnswers int + SurveyAnswers int + Attempted int +} + +func GetQuestionsData(analysis []models.QuizAnalysis, questionType string) [][]string { + questions := GetQuestionsByType(analysis, questionType) + + maxOptions := MaximumCountOfOptions(questions) + + // header + header := []string{ + "Question Text", + "Question Type", + } + for i := 1; i <= maxOptions; i++ { + header = append(header, fmt.Sprintf("Option %d", i)) + header = append(header, fmt.Sprintf("Option %d count", i)) + } + header = append(header, "Correct Answer", "Correct Answer Percentage") + + formatedQuestionData := [][]string{header} + + // rows + for _, question := range questions { + options := GetOptions(question) + correctAnsField := "" + + row := []string{question.Question} + + switch questionType { + case constants.AllString: + switch question.Type { + case 1: + row = append(row, constants.SingleAnsField) + case 2: + row = append(row, constants.SurveyString) + } + case constants.SingleString: + row = append(row, constants.SingleAnsField) + case constants.SurveyString: + row = append(row, constants.SurveyString) + } + + for _, option := range options { + row = append(row, option.Option, fmt.Sprintf("%d", option.SelectedCount)) + } + + missing := maxOptions - len(options) + for i := 0; i < missing; i++ { + row = append(row, "", "") + } + + for _, correctAns := range question.CorrectAnswers { + correctAnsField += fmt.Sprintf("%d|", correctAns) + } + correctAnsField = strings.TrimRight(correctAnsField, "|") + + row = append(row, correctAnsField, fmt.Sprintf("%.2f", CountPercentageOfGivenAns(question))) + + formatedQuestionData = append(formatedQuestionData, row) + } + + return formatedQuestionData +} + +func GetParticipantsData(analysis InputParticipants) [][]string { + userStats := GetUserStatics(analysis.QuizAnalysis) + + // CSV header + records := [][]string{ + {"Rank", "UserName", "Score", "Accuracy", "CorrectAnswers", "SurveyAnswers", "Attempted"}, + } + + for _, u := range analysis.FinalScoreBoardAdmin { + accuracy, correct, survey, attempted := 0, 0, 0, 0 + + if stat, ok := userStats[u.UserName]; ok { + accuracy = stat.Accuracy + correct = stat.CorrectAnswers + survey = stat.SurveyAnswers + attempted = stat.Attempted + } + + record := []string{ + strconv.Itoa(u.Rank), + u.UserName, + strconv.Itoa(u.Score), + strconv.Itoa(accuracy), + strconv.Itoa(correct), + strconv.Itoa(survey), + strconv.Itoa(attempted), + } + + records = append(records, record) + } + + return records +} + +func GetQuestionsByType(analysis []models.QuizAnalysis, questionType string) []models.QuizAnalysis { + var questions []models.QuizAnalysis + + if questionType == constants.SingleString { + for _, question := range analysis { + if question.Type == constants.SingleAnswer { + questions = append(questions, question) + } + } + return questions + } + if questionType == constants.SurveyString { + for _, question := range analysis { + if question.Type == constants.Survey { + questions = append(questions, question) + } + } + return questions + } + return analysis +} + +func GetOptions(question models.QuizAnalysis) []structs.OptionsWithSelectedCount { + questionOptions := make(map[string]structs.OptionsWithSelectedCount) + + for key, val := range question.Options { + questionOptions[key] = structs.OptionsWithSelectedCount{ + Option: val, + SelectedCount: 0, + } + } + + for _, raw := range question.SelectedAnswers { + if selected, ok := raw.([]interface{}); ok { + for _, s := range selected { + if optFloat, ok := s.(float64); ok { + optKey := fmt.Sprintf("%d", int(optFloat)) + if option, exists := questionOptions[optKey]; exists { + option.SelectedCount++ + questionOptions[optKey] = option + } + } + } + } + } + + orderedOptions := []structs.OptionsWithSelectedCount{} + for i := 1; i <= len(question.Options); i++ { + key := fmt.Sprintf("%d", i) + if opt, ok := questionOptions[key]; ok { + orderedOptions = append(orderedOptions, opt) + } + } + + return orderedOptions +} + +func CountPercentageOfGivenAns(question models.QuizAnalysis) float64 { + totalUser := len(question.SelectedAnswers) + correctCount := 0 + + for _, correct := range question.CorrectAnswers { + for _, selectedAns := range question.SelectedAnswers { + if arr, ok := selectedAns.([]interface{}); ok { + for _, v := range arr { + if option, ok := v.(float64); ok { + if int(option) == correct { + correctCount++ + } + } + } + } + + } + } + + totalPercentage := (float64(correctCount) / float64(totalUser)) * 100 + return totalPercentage +} + +func MaximumCountOfOptions(analysis []models.QuizAnalysis) int { + maxNumberOfOption := 0 + for _, que := range analysis { + lenOfOptions := len(que.Options) + if lenOfOptions > maxNumberOfOption { + maxNumberOfOption = lenOfOptions + } + } + return maxNumberOfOption +} + +func GetUserStatics(analysis []models.QuizAnalysis) map[string]UserStatics { + totalQuestion := 0 + userStats := make(map[string]UserStatics) + + for _, q := range analysis { + + for user, selectedRaw := range q.SelectedAnswers { + stat := userStats[user] + + var selected []int + if arr, ok := selectedRaw.([]interface{}); ok { + for _, v := range arr { + if num, ok := v.(float64); ok { + selected = append(selected, int(num)) + } + } + } + + if len(selected) > 0 { + stat.Attempted++ + } + + if q.Type == 2 { + stat.SurveyAnswers++ + } else { + if isCorrectAnswer(selected, q.CorrectAnswers) { + stat.CorrectAnswers++ + } + } + + userStats[user] = stat + } + totalQuestion++ + } + + for user, stat := range userStats { + if totalQuestion > 0 { + stat.Accuracy = int((float64(stat.CorrectAnswers+stat.SurveyAnswers) / float64(totalQuestion)) * 100) + } + userStats[user] = stat + } + + return userStats +} + +func isCorrectAnswer(selected, correct []int) bool { + if len(selected) == 0 || len(selected) != len(correct) { + return false + } + sort.Ints(selected) + sort.Ints(correct) + for i := range selected { + if selected[i] != correct[i] { + return false + } + } + return true +} diff --git a/api/utils/file_response.go b/api/utils/file_response.go new file mode 100644 index 00000000..c5fb06de --- /dev/null +++ b/api/utils/file_response.go @@ -0,0 +1,9 @@ +package utils + +import "github.com/gofiber/fiber/v2" + +func CsvFileResponse(c *fiber.Ctx, filePath string, fileName string) error { + c.Set("Content-Type", "text/csv") + c.Set("Content-Disposition", "attachment; filename="+fileName) + return c.SendFile(filePath) +} diff --git a/api/utils/json_response.go b/api/utils/json_response.go index 0b6dd6f0..a36d151e 100644 --- a/api/utils/json_response.go +++ b/api/utils/json_response.go @@ -53,3 +53,11 @@ func JSONErrorWs(c *websocket.Conn, eventName string, data interface{}) error { } } + +func JSONSuccessWithPagination(c *fiber.Ctx, statusCode int, data interface{}, totalCount int) error { + return c.Status(statusCode).JSON(map[string]interface{}{ + "status": "success", + "data": data, + "count": totalCount, + }) +} diff --git a/app/components/reports/DownloadDropdown.vue b/app/components/reports/DownloadDropdown.vue new file mode 100644 index 00000000..891d6661 --- /dev/null +++ b/app/components/reports/DownloadDropdown.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/app/components/reports/PageLayout.vue b/app/components/reports/PageLayout.vue index 74eebe31..cd8efedb 100644 --- a/app/components/reports/PageLayout.vue +++ b/app/components/reports/PageLayout.vue @@ -1,25 +1,53 @@ @@ -34,10 +62,52 @@ const props = defineProps({ type: String, required: true, }, + questionTypeFilter: { + default: "all", + type: String, + }, + userFilter: { + type: Object, + default: () => ({ + isAsc: true, + showTop10: false, + }), + }, + + totalQuestion: { + default: 0, + type: Number, + }, + allQuestion: { + default: 0, + type: Number, + }, }); -const emits = defineEmits(["changeTab"]); +const emits = defineEmits([ + "changeTab", + "update:questionTypeFilter", + "update:userFilter", +]); const changeComponent = (tab) => { emits("changeTab", tab); }; + + diff --git a/app/package-lock.json b/app/package-lock.json index 690f5924..463f0d17 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "app", "hasInstallScript": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.4.2", @@ -54,6 +55,7 @@ "nuxt": "^3.14.1592", "playwright-core": "^1.49.0", "prettier": "^2.8.2", + "typescript": "^5.1.6", "unplugin-vue-components": "^0.27.4", "vite-plugin-vuetify": "^2.0.1", "vitest": "^3.2.4", @@ -773,6 +775,20 @@ "node": ">=v14" } }, + "node_modules/@commitlint/load/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@commitlint/message": { "version": "17.8.1", "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-17.8.1.tgz", @@ -6121,14 +6137,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", - "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz", + "integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.38.0", - "@typescript-eslint/types": "^8.38.0", + "@typescript-eslint/tsconfig-utils": "^8.42.0", + "@typescript-eslint/types": "^8.42.0", "debug": "^4.3.4" }, "engines": { @@ -6139,13 +6155,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", - "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz", + "integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==", "dev": true, "license": "MIT", "engines": { @@ -6175,9 +6191,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", - "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz", + "integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==", "dev": true, "license": "MIT", "engines": { @@ -6188,7 +6204,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { @@ -9349,163 +9365,10 @@ "node": ">=18" } }, - "node_modules/detective-typescript": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.0.0.tgz", - "integrity": "sha512-pgN43/80MmWVSEi5LUuiVvO/0a9ss5V7fwVfrJ4QzAQRd3cwqU1SfWGXJFcNKUqoD5cS+uIovhw5t/0rSeC5Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "^8.23.0", - "ast-module-types": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "typescript": "^5.4.4" - } - }, - "node_modules/detective-typescript/node_modules/@typescript-eslint/types": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", - "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/detective-typescript/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", - "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.38.0", - "@typescript-eslint/tsconfig-utils": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/detective-typescript/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", - "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.38.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/detective-typescript/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/detective-typescript/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/detective-typescript/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/detective-typescript/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/detective-vue2": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/detective-vue2/-/detective-vue2-2.2.0.tgz", - "integrity": "sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@dependents/detective-less": "^5.0.1", - "@vue/compiler-sfc": "^3.5.13", - "detective-es6": "^5.0.1", - "detective-sass": "^6.0.1", - "detective-scss": "^5.0.1", - "detective-stylus": "^5.0.1", - "detective-typescript": "^14.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "typescript": "^5.4.4" - } - }, "node_modules/devalue": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", - "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz", + "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==", "dev": true, "license": "MIT" }, @@ -16430,6 +16293,77 @@ "node": ">=18" } }, + "node_modules/precinct/node_modules/@typescript-eslint/types": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz", + "integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/precinct/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz", + "integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.42.0", + "@typescript-eslint/tsconfig-utils": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/precinct/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz", + "integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/precinct/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/precinct/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -16440,6 +16374,102 @@ "node": ">=18" } }, + "node_modules/precinct/node_modules/detective-typescript": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.0.0.tgz", + "integrity": "sha512-pgN43/80MmWVSEi5LUuiVvO/0a9ss5V7fwVfrJ4QzAQRd3cwqU1SfWGXJFcNKUqoD5cS+uIovhw5t/0rSeC5Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "^8.23.0", + "ast-module-types": "^6.0.1", + "node-source-walk": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "typescript": "^5.4.4" + } + }, + "node_modules/precinct/node_modules/detective-vue2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/detective-vue2/-/detective-vue2-2.2.0.tgz", + "integrity": "sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dependents/detective-less": "^5.0.1", + "@vue/compiler-sfc": "^3.5.13", + "detective-es6": "^5.0.1", + "detective-sass": "^6.0.1", + "detective-scss": "^5.0.1", + "detective-stylus": "^5.0.1", + "detective-typescript": "^14.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "typescript": "^5.4.4" + } + }, + "node_modules/precinct/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/precinct/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/precinct/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/precinct/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -18672,9 +18702,9 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, "license": "MIT", "engines": { @@ -18899,9 +18929,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", "devOptional": true, "license": "Apache-2.0", "bin": { diff --git a/app/package.json b/app/package.json index 455952ce..14efb579 100644 --- a/app/package.json +++ b/app/package.json @@ -37,6 +37,7 @@ "nuxt": "^3.14.1592", "playwright-core": "^1.49.0", "prettier": "^2.8.2", + "typescript": "^5.1.6", "unplugin-vue-components": "^0.27.4", "vite-plugin-vuetify": "^2.0.1", "vitest": "^3.2.4", diff --git a/app/pages/admin/reports/[id]/index.vue b/app/pages/admin/reports/[id]/index.vue index 528dea29..4cf8dc5f 100644 --- a/app/pages/admin/reports/[id]/index.vue +++ b/app/pages/admin/reports/[id]/index.vue @@ -1,5 +1,12 @@