diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..50540f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Make a copy of this file and name it .env, then fill in the values below. +DEV=True +DB_URI= +DB_USER= +DB_PASSWORD= diff --git a/.gitignore b/.gitignore index 063be72..5f4ecc7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ package.json *.db *.ext +.env # Added by goreleaser init: dist/ diff --git a/cmd/pm/init.go b/cmd/pm/init.go index 5ba5040..95e3041 100644 --- a/cmd/pm/init.go +++ b/cmd/pm/init.go @@ -2,20 +2,31 @@ package main import ( "context" + "log" "os" "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/commands/issues" "github.com/LazyBachelor/LazyPM/internal/commands/survey" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/repl" "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/tui" "github.com/LazyBachelor/LazyPM/pkg/web" + "github.com/joho/godotenv" "github.com/spf13/cobra" ) func init() { + err := godotenv.Load(".env") + + if err != nil { + log.Println("Error loading .env file") + } + + models.BaseConfig = models.BaseConfig.LoadFromEnv() + task.RegisterInterface("tui", tui.New()) task.RegisterInterface("web", web.New()) task.RegisterInterface("repl", repl.New()) @@ -169,6 +180,8 @@ func commandNeedsApp(cmd *cobra.Command) bool { func ensureAppInitialized(ctx context.Context) error { if App != nil { + survey.SetApp(App) + issues.SetApp(App) return nil } diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index 1c14bae..ec1fd1d 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -5,10 +5,13 @@ import ( "errors" "fmt" "math/rand" + "time" "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" "github.com/LazyBachelor/LazyPM/internal/commands/survey" + "github.com/LazyBachelor/LazyPM/internal/storage" "github.com/LazyBachelor/LazyPM/pkg/task" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -28,6 +31,79 @@ func runStartCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("application services are not available") } + if !cmd.Flags().Changed("dev") { + var mongoStorage *storage.MongoStorage + var continueWithoutSubmitting bool + + for { + + if app.Config.DbUri == "" { + cmd.Println("No database URI provided in environment, survey responses will not be submitted.") + break + } + + db, err := storage.NewMongoStorageInteractive(cmd.Context(), app.Config.DbUri) + if err == nil { + mongoStorage = db + break + } + + cmd.Println("Failed to connect to database, survey responses will not be submitted.") + + if err := huh.NewConfirm(). + Title("Do you want to continue without submitting your responses?"). + Description("You can fix your database connection and submit your responses later with the submit command."). + Value(&continueWithoutSubmitting). + WithTheme(huh.ThemeBase16()). + RunAccessible(cmd.OutOrStdout(), cmd.InOrStdin()); err != nil { + return fmt.Errorf("failed to read user input: %w", err) + } + + if continueWithoutSubmitting { + break + } + } + + if mongoStorage != nil { + cmd.Println("Connected to Database Successfully. Starting survey...") + time.Sleep(2 * time.Second) + + defer mongoStorage.Close() + + ctx := cmd.Context() + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := mongoStorage.SubmitSurveyResponsesCmd(ctx, app.Config.AppDir); err != nil { + cmd.Printf("Failed to submit survey responses: %v\n", err) + } + } + } + }() + + if err := mongoStorage.SubmitSurveyResponsesCmd(ctx, app.Config.AppDir); err != nil { + cmd.Printf("Failed to submit survey responses: %v\n", err) + } + + defer func() { + if err := mongoStorage.SubmitSurveyResponsesCmd(context.Background(), app.Config.AppDir); err != nil { + cmd.Printf("Failed to submit survey responses on shutdown: %v\n", err) + } + }() + + } else { + cmd.Println("Starting survey without database connection. Your responses will not be submitted...") + time.Sleep(2 * time.Second) + } + } + interfaces := initInterfaces() surveyTasks := initTasks(app) @@ -49,11 +125,11 @@ func runStartCmd(cmd *cobra.Command, args []string) error { survey.Task: surveyTasks[survey.Task], } } - - if err := newIntroModel().Run(); err != nil { - return returnIfUserQuit(err, "failed to run intro") + if !cmd.Flags().Changed("dev") { + if err := newIntroModel().Run(); err != nil { + return returnIfUserQuit(err, "failed to run intro") + } } - if err := taskLoop(cmd.Context(), app, surveyTasks, interfaces); err != nil { return returnIfUserQuit(err, "task loop failed") } diff --git a/go.mod b/go.mod index a9ecc2f..c41c87b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.6 require ( charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 github.com/go-git/go-git/v6 v6.0.0-20260222090600-424e9964d3a3 + github.com/joho/godotenv v1.5.1 github.com/muesli/reflow v0.3.0 github.com/steveyegge/beads v0.49.6 go.mongodb.org/mongo-driver v1.17.9 diff --git a/go.sum b/go.sum index d159960..3ce74b3 100644 --- a/go.sum +++ b/go.sum @@ -132,6 +132,8 @@ github.com/haatos/goshipit v0.0.0-20260305043009-36e5c9a2e5c6 h1:Yakj3J1ZxYCpFtB github.com/haatos/goshipit v0.0.0-20260305043009-36e5c9a2e5c6/go.mod h1:2A31H3xgQHTgNp8OlX7aliXaY3QFwtI7XHuaP8G1ZSw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kevinburke/ssh_config v1.5.0 h1:3cPZmE54xb5j3G5xQCjSvokqNwU2uW+3ry1+PRLSPpA= github.com/kevinburke/ssh_config v1.5.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= diff --git a/internal/app/app.go b/internal/app/app.go index b44afa2..1ee6aa2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,6 +7,7 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/storage" "github.com/steveyegge/beads" + "go.mongodb.org/mongo-driver/bson/primitive" ) type App = models.App @@ -22,13 +23,13 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro } if !config.AutoInit { - if err := b.initializer.Init(config.BeadsDBPath); err != nil { + if err := b.initializer.Init(config.AppDir + "db.db"); err != nil { return nil, nil, err } } if b.issueService == nil { - sqliteStore, err := beads.NewSQLiteStorage(b.ctx, config.BeadsDBPath) + sqliteStore, err := beads.NewSQLiteStorage(b.ctx, config.AppDir+"db.db") if err != nil { return nil, nil, err } @@ -42,7 +43,7 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro if b.statsService == nil { statStore := storage.NewJsonStorage(config.StatisticsStoragePath, &models.Statistics{ - ID: 0, + ID: primitive.NewObjectID(), StartTime: time.Now(), }) diff --git a/internal/app/statistics.go b/internal/app/statistics.go index dadc342..cdf27e4 100644 --- a/internal/app/statistics.go +++ b/internal/app/statistics.go @@ -9,6 +9,7 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/storage" + "go.mongodb.org/mongo-driver/bson/primitive" ) type StatisticsService struct { @@ -42,6 +43,16 @@ func (s *StatisticsService) GetStatistics() (models.Statistics, error) { return *s.storage.Data, nil } +func (s *StatisticsService) GetParticipantID() primitive.ObjectID { + s.mu.Lock() + defer s.mu.Unlock() + + if s.storage.Data == nil { + return primitive.NilObjectID + } + return s.storage.Data.ID +} + func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRunMetrics) error { _ = ctx @@ -63,8 +74,8 @@ func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRu } stats.EndTime = now - stats.Duration = stats.EndTime.Sub(stats.StartTime) - stats.InterfaceType = run.InterfaceType + stats.DurationMs = stats.EndTime.Sub(stats.StartTime).Milliseconds() + stats.LastInterfaceType = run.InterfaceType stats.TaskRuns++ stats.LastTaskName = run.TaskName diff --git a/internal/commands/survey/root.go b/internal/commands/survey/root.go index 41a4d21..f148f08 100644 --- a/internal/commands/survey/root.go +++ b/internal/commands/survey/root.go @@ -26,11 +26,6 @@ Your responses will be kept confidential and used solely for research purposes.` var RootCmd = &cobra.Command{ Use: "survey", Long: long, - PersistentPreRun: func(cmd *cobra.Command, args []string) { - if app != nil { - cmd.SetContext(context.WithValue(cmd.Context(), appKey, app)) - } - }, } func SetApp(application *App) { diff --git a/internal/commands/survey/start.go b/internal/commands/survey/start.go index bd2676b..a7a9cfd 100644 --- a/internal/commands/survey/start.go +++ b/internal/commands/survey/start.go @@ -7,6 +7,7 @@ import ( ) var ( + DevFlag bool InterfaceType string Task string ) @@ -22,4 +23,5 @@ func init() { StartCmd.Flags().StringVarP(&InterfaceType, "interface", "i", "", "Specify interface.") StartCmd.RegisterFlagCompletionFunc("task", shellcomp.CompletionFunc(task.ListTasks())) StartCmd.RegisterFlagCompletionFunc("interface", shellcomp.CompletionFunc(task.ListInterfaces())) + StartCmd.Flags().BoolVar(&DevFlag, "dev", false, "Enable development mode, which skips database connection, submission and intro") } diff --git a/internal/commands/survey/submit.go b/internal/commands/survey/submit.go index 8d0a7b2..bcdd77a 100644 --- a/internal/commands/survey/submit.go +++ b/internal/commands/survey/submit.go @@ -1,123 +1,39 @@ package survey import ( - "encoding/json" "fmt" - "os" - "strings" - "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/storage" "github.com/spf13/cobra" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" ) -var mongoURI = os.Getenv("MONGODB_URI") - var SubmitCmd = &cobra.Command{ Use: "submit", Short: "Submit your survey responses", RunE: func(cmd *cobra.Command, args []string) error { - client, error := mongo.Connect(cmd.Context(), options.Client().ApplyURI(mongoURI)) - if error != nil { - return fmt.Errorf("Failed to connect to MongoDB: %v", error) + app := AppFromContext(cmd.Context()) + + if app == nil { + return fmt.Errorf("application context not initialized") } - go func() { - if err := client.Disconnect(cmd.Context()); err != nil { - fmt.Printf("Failed to disconnect MongoDB client: %v", err) - } - }() + if app.Config.DbUri == "" { + cmd.Println("No database URI provided in environment, survey responses will not be submitted.") + return nil + } - userStatscollection := client.Database("Responses").Collection("stats") - taskMetricsCollection := client.Database("Responses").Collection("task_metrics") - - pmDir := "./.pm/" - - entries, err := os.ReadDir(pmDir) + db, err := storage.NewMongoStorageInteractive(cmd.Context(), app.Config.DbUri) if err != nil { - return fmt.Errorf("Failed to read .pm directory: %v", err) + return fmt.Errorf("failed to connect to database: %w", err) } - if len(entries) == 0 { - return fmt.Errorf("No files found in .pm directory") + defer db.Close() + + if err := db.SubmitSurveyResponsesCmd(cmd.Context(), app.Config.AppDir); err != nil { + return fmt.Errorf("failed to submit survey responses: %w", err) } - statFile := pmDir + "stats.json" - if _, err := os.Stat(statFile); os.IsNotExist(err) { - return fmt.Errorf("stats.json not found in .pm directory") - } - - stats, err := getStats(statFile) - if err != nil { - return fmt.Errorf("Failed to read stats.json: %v", err) - } - - _, err = userStatscollection.InsertOne(cmd.Context(), stats) - if err != nil { - return fmt.Errorf("Failed to insert stats into database: %v", err) - } - - metricFiles := []string{} - for _, entry := range entries { - if entry.IsDir() { - continue - } - - if entry.Name() == "stats.json" { - continue - } - - if strings.HasSuffix(entry.Name(), "-stats.json") { - metricFiles = append(metricFiles, pmDir+entry.Name()) - continue - } - } - - for _, file := range metricFiles { - metrics, err := getTaskMetrics(file) - if err != nil { - fmt.Printf("failed to read metrics from %s: %v", file, err) - continue - } - - _, err = taskMetricsCollection.InsertOne(cmd.Context(), metrics) - if err != nil { - fmt.Printf("failed to insert metrics from %s: %v", file, err) - continue - } - } - - cmd.Printf("Successfully submitted survey responses and metrics to the database") - + cmd.Println("Successfully submitted survey responses and metrics to the database") return nil }, } - -func getStats(file string) (*models.Statistics, error) { - data, err := os.ReadFile(file) - if err != nil { - return nil, err - } - - var stats models.Statistics - if err := json.Unmarshal(data, &stats); err != nil { - return nil, err - } - - return &stats, nil -} - -func getTaskMetrics(file string) (*models.TaskMetricsFile, error) { - data, err := os.ReadFile(file) - if err != nil { - return nil, err - } - - var metrics models.TaskMetricsFile - if err := json.Unmarshal(data, &metrics); err != nil { - return nil, err - } - - return &metrics, nil -} diff --git a/internal/models/app.go b/internal/models/app.go index 5ba33b1..9b81226 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -3,6 +3,8 @@ package models import ( "context" "log/slog" + + "go.mongodb.org/mongo-driver/bson/primitive" ) type App struct { @@ -57,5 +59,6 @@ type StatsService interface { Load(ctx context.Context) error Save(ctx context.Context) error GetStatistics() (Statistics, error) + GetParticipantID() primitive.ObjectID RecordTaskRun(ctx context.Context, run TaskRunMetrics) error } diff --git a/internal/models/config.go b/internal/models/config.go index 48a7f62..4bbf09d 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -1,24 +1,40 @@ package models +import "os" + type Config struct { + DbUri string AutoInit bool RootCmd string + AppDir string WebAddress string - BeadsDBPath string IssuePrefix string - StatisticsStoragePath string ActionLogger func(string) + StatisticsStoragePath string } var BaseConfig = Config{ + DbUri: "", AutoInit: false, RootCmd: "pm", IssuePrefix: "pm", WebAddress: ":8080", - BeadsDBPath: "./.pm/db.db", + AppDir: "./.pm/", StatisticsStoragePath: "./.pm/stats.json", } +func (c Config) LoadFromEnv() Config { + if dbURI, ok := os.LookupEnv("DB_URI"); ok { + c.DbUri = dbURI + } + return c +} + +func (c Config) WithDbUri(uri string) Config { + c.DbUri = uri + return c +} + func (c Config) WithAutoInit(autoInit bool) Config { c.AutoInit = autoInit return c @@ -34,11 +50,6 @@ func (c Config) WithWebAddress(webAddress string) Config { return c } -func (c Config) WithBeadsDBPath(beadsDBPath string) Config { - c.BeadsDBPath = beadsDBPath - return c -} - func (c Config) WithIssuePrefix(issuePrefix string) Config { c.IssuePrefix = issuePrefix return c diff --git a/internal/models/statistics.go b/internal/models/statistics.go index b151072..41041ac 100644 --- a/internal/models/statistics.go +++ b/internal/models/statistics.go @@ -2,91 +2,95 @@ package models import ( "time" + + "go.mongodb.org/mongo-driver/bson/primitive" ) type Statistics struct { - ID int `json:"id"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - Duration time.Duration `json:"duration"` + ID primitive.ObjectID `bson:"_id" json:"id"` + StartTime time.Time `bson:"start_time" json:"start_time"` + EndTime time.Time `bson:"end_time" json:"end_time"` + DurationMs int64 `bson:"duration_ms" json:"duration_ms"` - InterfaceType InterfaceType `json:"interface_type"` - TaskRuns int `json:"task_runs"` - TasksCompleted int `json:"tasks_completed"` - TasksFailed int `json:"tasks_failed"` + LastInterfaceType InterfaceType `bson:"last_interface_type" json:"last_interface_type"` + TaskRuns int `bson:"task_runs" json:"task_runs"` + TasksCompleted int `bson:"tasks_completed" json:"tasks_completed"` + TasksFailed int `bson:"tasks_failed" json:"tasks_failed"` - TotalDurationMs int64 `json:"total_duration_ms"` - AverageDurationMs int64 `json:"average_duration_ms"` + TotalDurationMs int64 `bson:"total_duration_ms" json:"total_duration_ms"` + AverageDurationMs int64 `bson:"average_duration_ms" json:"average_duration_ms"` - TotalUserActions int `json:"total_user_actions"` - QuestionnairesCompleted int `json:"questionnaires_completed"` - QuestionnairesAbandoned int `json:"questionnaires_abandoned"` + TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"` + QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"` + QuestionnairesAbandoned int `bson:"questionnaires_abandoned" json:"questionnaires_abandoned"` - ValidationAttempts int `json:"validation_attempts"` - ValidationSuccesses int `json:"validation_successes"` - ValidationFailures int `json:"validation_failures"` - ValidationChecksPassed int `json:"validation_checks_passed"` - ValidationChecksFailed int `json:"validation_checks_failed"` + ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"` + ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"` + ValidationFailures int `bson:"validation_failures" json:"validation_failures"` + ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"` + ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"` - LastTaskName string `json:"last_task_name"` - LastRunID int `json:"last_run_id"` + LastTaskName string `bson:"last_task_name" json:"last_task_name"` + LastRunID int `bson:"last_run_id" json:"last_run_id"` } type TaskMetricsFile struct { - TaskName string `json:"task_name"` - UpdatedAt time.Time `json:"updated_at"` - Summary TaskStatsSummary `json:"summary"` - Runs []TaskRunMetrics `json:"runs"` + ID primitive.ObjectID `bson:"_id" json:"id"` + ParticipantID primitive.ObjectID `bson:"participant_id" json:"participant_id"` + TaskName string `bson:"task_name" json:"task_name"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` + Summary TaskStatsSummary `bson:"summary" json:"summary"` + Runs []TaskRunMetrics `bson:"runs" json:"runs"` } type TaskStatsSummary struct { - TotalRuns int `json:"total_runs"` - CompletedRuns int `json:"completed_runs"` - IncompleteRuns int `json:"incomplete_runs"` - TotalDurationMs int64 `json:"total_duration_ms"` - AverageDurationMs int64 `json:"average_duration_ms"` - TotalUserActions int `json:"total_user_actions"` - QuestionnairesCompleted int `json:"questionnaires_completed"` - QuestionnairesAbandoned int `json:"questionnaires_abandoned"` - ValidationAttempts int `json:"validation_attempts"` - ValidationSuccesses int `json:"validation_successes"` - ValidationFailures int `json:"validation_failures"` - ValidationChecksPassed int `json:"validation_checks_passed"` - ValidationChecksFailed int `json:"validation_checks_failed"` - LastInterfaceType InterfaceType `json:"last_interface_type"` - FirstRunStartedAt time.Time `json:"first_run_started_at"` - LastRunStartedAt time.Time `json:"last_run_started_at"` - LastRunEndedAt time.Time `json:"last_run_ended_at"` + TotalRuns int `bson:"total_runs" json:"total_runs"` + CompletedRuns int `bson:"completed_runs" json:"completed_runs"` + IncompleteRuns int `bson:"incomplete_runs" json:"incomplete_runs"` + TotalDurationMs int64 `bson:"total_duration_ms" json:"total_duration_ms"` + AverageDurationMs int64 `bson:"average_duration_ms" json:"average_duration_ms"` + TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"` + QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"` + QuestionnairesAbandoned int `bson:"questionnaires_abandoned" json:"questionnaires_abandoned"` + ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"` + ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"` + ValidationFailures int `bson:"validation_failures" json:"validation_failures"` + ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"` + ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"` + LastInterfaceType InterfaceType `bson:"last_interface_type" json:"last_interface_type"` + FirstRunStartedAt time.Time `bson:"first_run_started_at" json:"first_run_started_at"` + LastRunStartedAt time.Time `bson:"last_run_started_at" json:"last_run_started_at"` + LastRunEndedAt time.Time `bson:"last_run_ended_at" json:"last_run_ended_at"` } type TaskRunMetrics struct { - RunID int `json:"run_id"` - TaskName string `json:"task_name"` - InterfaceType InterfaceType `json:"interface_type"` - StartedAt time.Time `json:"started_at"` - EndedAt time.Time `json:"ended_at"` - DurationMs int64 `json:"duration_ms"` - Completed bool `json:"completed"` - ValidationAttempts int `json:"validation_attempts"` - ValidationSuccesses int `json:"validation_successes"` - ValidationFailures int `json:"validation_failures"` - ValidationChecksPassed int `json:"validation_checks_passed"` - ValidationChecksFailed int `json:"validation_checks_failed"` - LastValidationMessage string `json:"last_validation_message,omitempty"` - QuestionnaireCompleted bool `json:"questionnaire_completed"` - QuestionnaireUserQuit bool `json:"questionnaire_user_quit"` - QuestionnaireAnswers map[string]any `json:"questionnaire_answers,omitempty"` - Logs []TaskLogEntry `json:"logs"` - Error string `json:"error,omitempty"` + RunID int `bson:"run_id" json:"run_id"` + TaskName string `bson:"task_name" json:"task_name"` + InterfaceType InterfaceType `bson:"interface_type" json:"interface_type"` + StartedAt time.Time `bson:"started_at" json:"started_at"` + EndedAt time.Time `bson:"ended_at" json:"ended_at"` + DurationMs int64 `bson:"duration_ms" json:"duration_ms"` + Completed bool `bson:"completed" json:"completed"` + ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"` + ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"` + ValidationFailures int `bson:"validation_failures" json:"validation_failures"` + ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"` + ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"` + LastValidationMessage string `bson:"last_validation_message,omitempty" json:"last_validation_message,omitempty"` + QuestionnaireCompleted bool `bson:"questionnaire_completed" json:"questionnaire_completed"` + QuestionnaireUserQuit bool `bson:"questionnaire_user_quit" json:"questionnaire_user_quit"` + QuestionnaireAnswers map[string]any `bson:"questionnaire_answers,omitempty" json:"questionnaire_answers,omitempty"` + Logs []TaskLogEntry `bson:"logs" json:"logs"` + Error string `bson:"error,omitempty" json:"error,omitempty"` } type TaskLogEntry struct { - Timestamp time.Time `json:"timestamp"` - Level string `json:"level"` - Message string `json:"message"` - Source string `json:"source,omitempty"` - Action string `json:"action,omitempty"` - Target string `json:"target,omitempty"` - Result string `json:"result,omitempty"` - Attempt int `json:"attempt,omitempty"` + Timestamp time.Time `bson:"timestamp" json:"timestamp"` + Level string `bson:"level" json:"level"` + Message string `bson:"message" json:"message"` + Source string `bson:"source,omitempty" json:"source,omitempty"` + Action string `bson:"action,omitempty" json:"action,omitempty"` + Target string `bson:"target,omitempty" json:"target,omitempty"` + Result string `bson:"result,omitempty" json:"result,omitempty"` + Attempt int `bson:"attempt,omitempty" json:"attempt,omitempty"` } diff --git a/internal/storage/mongo.go b/internal/storage/mongo.go new file mode 100644 index 0000000..474b819 --- /dev/null +++ b/internal/storage/mongo.go @@ -0,0 +1,180 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/charmbracelet/huh" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type MongoStorage struct { + client *mongo.Client +} + +func NewMongoStorage(ctx context.Context, uri, username, password string) (*MongoStorage, error) { + credentials := options.Credential{ + Username: username, + Password: password, + } + + client, err := mongo.Connect(ctx, + options.Client().ApplyURI(uri).SetAuth(credentials)) + + if err != nil { + return nil, fmt.Errorf("failed to connect to MongoDB: %v", err) + } + + if err := client.Ping(ctx, nil); err != nil { + return nil, fmt.Errorf("cannot reach MongoDB: %v", err) + } + + return &MongoStorage{client: client}, nil +} + +func NewMongoStorageInteractive(ctx context.Context, uri string) (*MongoStorage, error) { + var username, password string + if os.Getenv("DB_USER") == "" { + if err := huh.NewInput(). + Title("Enter the Database Username"). + Value(&username). + WithTheme(huh.ThemeBase16()).Run(); err != nil { + return nil, fmt.Errorf("failed to read username: %w", err) + } + } else { + username = os.Getenv("DB_USER") + } + + if username == "" { + return nil, fmt.Errorf("No username provided.") + } + + if os.Getenv("DB_PASSWORD") == "" { + if err := huh.NewInput(). + Title("Enter the Survey Password"). + EchoMode(huh.EchoModePassword). + Value(&password). + WithTheme(huh.ThemeBase16()).Run(); err != nil { + return nil, fmt.Errorf("failed to read password: %w", err) + } + } else { + password = os.Getenv("DB_PASSWORD") + } + + if password == "" { + return nil, fmt.Errorf("No password provided.") + + } + + mongoClient, err := NewMongoStorage(ctx, uri, username, password) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + return mongoClient, nil +} + +func (s *MongoStorage) Close() error { + return s.client.Disconnect(context.Background()) +} + +func (s *MongoStorage) SubmitSurveyResponsesCmd(ctx context.Context, dir string) error { + pmDir := dir + userStatscollection := s.client.Database("Responses").Collection("stats") + taskMetricsCollection := s.client.Database("Responses").Collection("metrics") + + entries, err := os.ReadDir(pmDir) + if err != nil { + return fmt.Errorf("Failed to read .pm directory: %v", err) + } + + if len(entries) == 0 { + return fmt.Errorf("No files found in .pm directory") + } + + statFile := pmDir + "stats.json" + if _, err := os.Stat(statFile); os.IsNotExist(err) { + return fmt.Errorf("stats.json not found in .pm directory") + } + + stats, err := getStats(statFile) + if err != nil { + return fmt.Errorf("Failed to read stats.json: %v", err) + } + + _, err = userStatscollection.UpdateOne(ctx, + bson.M{"_id": stats.ID}, + bson.M{"$set": stats}, + options.Update().SetUpsert(true)) + + if err != nil { + return fmt.Errorf("Failed to insert stats into database: %v", err) + } + + metricFiles := []string{} + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if entry.Name() == "stats.json" { + continue + } + + if strings.HasSuffix(entry.Name(), "-stats.json") { + metricFiles = append(metricFiles, pmDir+entry.Name()) + continue + } + } + + for _, file := range metricFiles { + metrics, err := getTaskMetrics(file) + if err != nil { + fmt.Printf("failed to read metrics from %s: %v", file, err) + continue + } + + _, err = taskMetricsCollection.UpdateOne(ctx, bson.M{"_id": metrics.ID}, bson.M{"$set": metrics}, + options.Update().SetUpsert(true)) + + if err != nil { + fmt.Printf("failed to insert metrics from %s: %v", file, err) + continue + } + } + + return nil +} + +func getStats(file string) (*models.Statistics, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var stats models.Statistics + if err := json.Unmarshal(data, &stats); err != nil { + return nil, err + } + + return &stats, nil +} + +func getTaskMetrics(file string) (*models.TaskMetricsFile, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var metrics models.TaskMetricsFile + if err := json.Unmarshal(data, &metrics); err != nil { + return nil, err + } + + return &metrics, nil +} diff --git a/pkg/task/lifecycle.go b/pkg/task/lifecycle.go index 8a9fa29..75b18d9 100644 --- a/pkg/task/lifecycle.go +++ b/pkg/task/lifecycle.go @@ -4,6 +4,7 @@ import ( "log/slog" "github.com/LazyBachelor/LazyPM/internal/models" + "go.mongodb.org/mongo-driver/bson/primitive" ) type RunLifecycle struct { @@ -23,9 +24,11 @@ func NewRunLifecycle(app *App, config Config, details models.TaskDetails, iType collector.recordUserAction(action) }) + var participantID primitive.ObjectID var store MetricsStore if config.StatisticsStoragePath != "" { - store = NewFileMetricsStore(config.StatisticsStoragePath, logger) + participantID = app.Stats.GetParticipantID() + store = NewFileMetricsStore(config.StatisticsStoragePath, participantID, logger) } return &RunLifecycle{ diff --git a/pkg/task/metrics_store.go b/pkg/task/metrics_store.go index ae69f25..a499de8 100644 --- a/pkg/task/metrics_store.go +++ b/pkg/task/metrics_store.go @@ -10,6 +10,7 @@ import ( "time" "github.com/LazyBachelor/LazyPM/internal/models" + "go.mongodb.org/mongo-driver/bson/primitive" ) type MetricsStore interface { @@ -17,14 +18,16 @@ type MetricsStore interface { } type FileMetricsStore struct { - path string - logger *slog.Logger + path string + participantID primitive.ObjectID + logger *slog.Logger } -func NewFileMetricsStore(path string, logger *slog.Logger) *FileMetricsStore { +func NewFileMetricsStore(path string, participantID primitive.ObjectID, logger *slog.Logger) *FileMetricsStore { return &FileMetricsStore{ - path: path, - logger: logger, + path: path, + participantID: participantID, + logger: logger, } } @@ -41,8 +44,10 @@ func (s *FileMetricsStore) Append(ctx context.Context, taskName string, run mode } metrics := models.TaskMetricsFile{ - TaskName: taskName, - Runs: []models.TaskRunMetrics{}, + ID: primitive.NewObjectID(), + ParticipantID: s.participantID, + TaskName: taskName, + Runs: []models.TaskRunMetrics{}, } if err := readMetrics(&metrics, s.path); err != nil {