diff --git a/backend/internal/repository/analytics.go b/backend/internal/repository/analytics.go index 728472b2a..34a1db38a 100644 --- a/backend/internal/repository/analytics.go +++ b/backend/internal/repository/analytics.go @@ -41,29 +41,29 @@ func (r *Repository) RecordUserActivity(userID string, event string, count int, switch event { case "login": activity.LoginCount = count - updates["login_count"] = gorm.Expr("login_count + ?", count) + updates["login_count"] = gorm.Expr("user_daily_activities.login_count + ?", count) case "task": activity.TaskCount = count - updates["task_count"] = gorm.Expr("task_count + ?", count) + updates["task_count"] = gorm.Expr("user_daily_activities.task_count + ?", count) case "agent_message": activity.AgentMessageCount = count - updates["agent_message_count"] = gorm.Expr("agent_message_count + ?", count) + updates["agent_message_count"] = gorm.Expr("user_daily_activities.agent_message_count + ?", count) case "canvas": activity.CanvasActive = true updates["canvas_active"] = true case "asset": activity.AssetCount = count - updates["asset_count"] = gorm.Expr("asset_count + ?", count) + updates["asset_count"] = gorm.Expr("user_daily_activities.asset_count + ?", count) case "resource": activity.ResourceCount = count - updates["resource_count"] = gorm.Expr("resource_count + ?", count) + updates["resource_count"] = gorm.Expr("user_daily_activities.resource_count + ?", count) default: return nil } if event != "login" { activity.FirstActiveAt = &now activity.LastActiveAt = &now - updates["first_active_at"] = gorm.Expr("COALESCE(first_active_at, ?)", now) + updates["first_active_at"] = gorm.Expr("COALESCE(user_daily_activities.first_active_at, ?)", now) updates["last_active_at"] = now } return r.db.Clauses(clause.OnConflict{ diff --git a/backend/internal/repository/analytics_test.go b/backend/internal/repository/analytics_test.go new file mode 100644 index 000000000..f3699884b --- /dev/null +++ b/backend/internal/repository/analytics_test.go @@ -0,0 +1,73 @@ +package repository + +import ( + "context" + "strings" + "testing" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type sqlCaptureLogger struct { + statements []string +} + +func (l *sqlCaptureLogger) LogMode(logger.LogLevel) logger.Interface { return l } +func (*sqlCaptureLogger) Info(context.Context, string, ...any) {} +func (*sqlCaptureLogger) Warn(context.Context, string, ...any) {} +func (*sqlCaptureLogger) Error(context.Context, string, ...any) {} + +func (l *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, query func() (string, int64), _ error) { + statement, _ := query() + l.statements = append(l.statements, statement) +} + +func TestRecordUserActivityQualifiesPostgresConflictColumns(t *testing.T) { + capture := &sqlCaptureLogger{} + db, err := gorm.Open(postgres.New(postgres.Config{ + DSN: "host=localhost user=test dbname=test sslmode=disable", + PreferSimpleProtocol: true, + }), &gorm.Config{ + DisableAutomaticPing: true, + DryRun: true, + Logger: capture, + SkipDefaultTransaction: true, + }) + if err != nil { + t.Fatalf("open dry-run postgres database: %v", err) + } + + repo := New(db) + cases := []struct { + event string + column string + }{ + {event: "login", column: "login_count"}, + {event: "task", column: "task_count"}, + {event: "agent_message", column: "agent_message_count"}, + {event: "asset", column: "asset_count"}, + {event: "resource", column: "resource_count"}, + } + + for _, tc := range cases { + t.Run(tc.event, func(t *testing.T) { + capture.statements = nil + if err := repo.RecordUserActivity("user-1", tc.event, 1, time.Unix(1_700_000_000, 0)); err != nil { + t.Fatalf("record activity: %v", err) + } + if len(capture.statements) == 0 { + t.Fatal("expected generated SQL") + } + statement := capture.statements[len(capture.statements)-1] + if !strings.Contains(statement, "user_daily_activities."+tc.column) { + t.Fatalf("conflict update column is not target-qualified: %s", statement) + } + if tc.event != "login" && !strings.Contains(statement, "COALESCE(user_daily_activities.first_active_at") { + t.Fatalf("first active column is not target-qualified: %s", statement) + } + }) + } +}