Unverified Commit ce4bcad6 authored by Scarlett Perry's avatar Scarlett Perry Committed by GitHub
Browse files

feedback: add SubmitFeedback implementation (#1874)

parent 82dfb3c2
Showing with 2217 additions and 1593 deletions
+2217 -1593
......@@ -7,18 +7,10 @@ option go_package = "github.com/lyft/clutch/backend/api/config/module/feedback/v
import "validate/validate.proto";
import "feedback/v1/feedback.proto";
message RatingOptions {
// the text (i.e. "bad", "ok", "great") for each value in the rating system
// currently a three-point rating system (and UI designs) are supported
string one = 1 [ (validate.rules).string = {min_bytes : 1} ];
string two = 2 [ (validate.rules).string = {min_bytes : 1} ];
string three = 3 [ (validate.rules).string = {min_bytes : 1} ];
}
message Survey {
string prompt = 1 [ (validate.rules).string = {min_bytes : 1} ];
string freeform_prompt = 2;
RatingOptions rating_options = 3 [ (validate.rules).message = {required : true} ];
clutch.feedback.v1.RatingLabels rating_labels = 3 [ (validate.rules).message = {required : true} ];
}
message SurveyOrigin {
......
......@@ -7,7 +7,6 @@ option go_package = "github.com/lyft/clutch/backend/api/feedback/v1;feedbackv1";
import "google/api/annotations.proto";
import "api/v1/annotations.proto";
import "validate/validate.proto";
import "google/protobuf/timestamp.proto";
service FeedbackAPI {
rpc GetSurveys(GetSurveysRequest) returns (GetSurveysResponse) {
......@@ -33,6 +32,42 @@ enum Origin {
WIZARD = 2;
}
// currently UI components and designs support a three-point emoji scale. as more use cases arise,
// we can expand to add more emoji options (i.e. for a 4-point scale) and
// new rating scales (i.e 2-point thumbs up/down scale).
message EmojiRatingLabels {
// the corresponding option to show to the user (i.e bad/ok/great)
string sad = 1;
string neutral = 2;
string happy = 3;
}
enum EmojiRating {
// these are used to compute a feedback score out of 100
EMOJI_UNSPECIFIED = 0;
SAD = 1;
NEUTRAL = 2;
HAPPY = 3;
}
// Rating labels are the raw text options that are presented to the user (i.e. bad/ok/great)
// whereas a rating scale gets normalized to a score out of a 100 to compute a NPS score.
message RatingLabels {
oneof type {
option (validate.required) = true;
EmojiRatingLabels emoji = 1;
}
}
message RatingScale {
oneof type {
option (validate.required) = true;
EmojiRating emoji = 1 [ (validate.rules).enum = {defined_only : true not_in : 0} ];
}
}
message GetSurveysRequest {
// the origin of the feedback entry. multiple origins can be passed in the request to return their specific survey
repeated Origin origins = 1 [ (validate.rules).repeated = {
......@@ -42,21 +77,13 @@ message GetSurveysRequest {
// future: add a user field if rules are implemented for whether a user should see the feedback survey
}
message RatingOptions {
// the text (i.e. "bad", "ok", "great") for each value in the rating system
// currently a three-point rating system (and UI designs) are supported
string one = 1;
string two = 2;
string three = 3;
}
message Survey {
// the prompt for the rating options
string prompt = 1;
// the prompt for the freeform feedback
string freeform_prompt = 2;
// feedback options to present to the user (i.e. "bad", "ok", "great")
RatingOptions rating_options = 3;
// the text options to show to the user (i.e. bad/ok/great) for the corresponding rating scale
RatingLabels rating_labels = 3;
}
message GetSurveysResponse {
......@@ -74,34 +101,29 @@ message FeedbackMetadata {
}
message Feedback {
// user's email
string user_id = 1 [ (validate.rules).string = {min_bytes : 1} ];
// url path of where the feedback was submitted
string url_path = 2 [ (validate.rules).string = {min_bytes : 1} ];
// rating option the user selected (i.e. "bad", "ok", "great")
string rating = 3 [ (validate.rules).string = {min_bytes : 1} ];
string url_path = 1 [ (validate.rules).string = {min_bytes : 1} ];
// the text option the user selected (i.e. bad/ok/great)
string rating_label = 2 [ (validate.rules).string = {min_bytes : 1} ];
// the corresponding rating scale selection
RatingScale rating_scale = 3 [ (validate.rules).message = {required : true} ];
// (optional) freeform input
string freeform_response = 4;
// (optional) some UI components (i.e the header) will have a dropdown menu for choosing the type of
// feedback to submit (i.e. "General", "K8s Delete Pod")
string feedback_type = 5;
FeedbackMetadata metadata = 6 [ (validate.rules).message = {required : true} ];
}
message SubmitFeedbackRequest {
// client-genereated unique feedback id, which we will also use to update the feedback (essentially replace with the
// latest)
// TODO: remove if we decide to record feedback only when a user clicks the submit button
string id = 1 [ (validate.rules).string = {min_bytes : 1} ];
Feedback feedback = 2 [ (validate.rules).message = {required : true} ];
string id = 1 [ (validate.rules).string.len = 36 ];
// user's email
string user_id = 2 [ (validate.rules).string = {min_bytes : 1} ];
Feedback feedback = 3 [ (validate.rules).message = {required : true} ];
FeedbackMetadata metadata = 4 [ (validate.rules).message = {required : true} ];
}
message SubmitFeedbackResponse {
}
// proto used by the Feedback service
message Submission {
// timestamp will be populated by the server
google.protobuf.Timestamp submitted_at = 1;
Feedback feedback = 2;
}
......@@ -22,85 +22,20 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type RatingOptions struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// the text (i.e. "bad", "ok", "great") for each value in the rating system
// currently a three-point rating system (and UI designs) are supported
One string `protobuf:"bytes,1,opt,name=one,proto3" json:"one,omitempty"`
Two string `protobuf:"bytes,2,opt,name=two,proto3" json:"two,omitempty"`
Three string `protobuf:"bytes,3,opt,name=three,proto3" json:"three,omitempty"`
}
func (x *RatingOptions) Reset() {
*x = RatingOptions{}
if protoimpl.UnsafeEnabled {
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RatingOptions) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RatingOptions) ProtoMessage() {}
func (x *RatingOptions) ProtoReflect() protoreflect.Message {
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RatingOptions.ProtoReflect.Descriptor instead.
func (*RatingOptions) Descriptor() ([]byte, []int) {
return file_config_module_feedback_v1_feedback_proto_rawDescGZIP(), []int{0}
}
func (x *RatingOptions) GetOne() string {
if x != nil {
return x.One
}
return ""
}
func (x *RatingOptions) GetTwo() string {
if x != nil {
return x.Two
}
return ""
}
func (x *RatingOptions) GetThree() string {
if x != nil {
return x.Three
}
return ""
}
type Survey struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Prompt string `protobuf:"bytes,1,opt,name=prompt,proto3" json:"prompt,omitempty"`
FreeformPrompt string `protobuf:"bytes,2,opt,name=freeform_prompt,json=freeformPrompt,proto3" json:"freeform_prompt,omitempty"`
RatingOptions *RatingOptions `protobuf:"bytes,3,opt,name=rating_options,json=ratingOptions,proto3" json:"rating_options,omitempty"`
Prompt string `protobuf:"bytes,1,opt,name=prompt,proto3" json:"prompt,omitempty"`
FreeformPrompt string `protobuf:"bytes,2,opt,name=freeform_prompt,json=freeformPrompt,proto3" json:"freeform_prompt,omitempty"`
RatingLabels *v1.RatingLabels `protobuf:"bytes,3,opt,name=rating_labels,json=ratingLabels,proto3" json:"rating_labels,omitempty"`
}
func (x *Survey) Reset() {
*x = Survey{}
if protoimpl.UnsafeEnabled {
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[1]
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
......@@ -113,7 +48,7 @@ func (x *Survey) String() string {
func (*Survey) ProtoMessage() {}
func (x *Survey) ProtoReflect() protoreflect.Message {
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[1]
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
......@@ -126,7 +61,7 @@ func (x *Survey) ProtoReflect() protoreflect.Message {
// Deprecated: Use Survey.ProtoReflect.Descriptor instead.
func (*Survey) Descriptor() ([]byte, []int) {
return file_config_module_feedback_v1_feedback_proto_rawDescGZIP(), []int{1}
return file_config_module_feedback_v1_feedback_proto_rawDescGZIP(), []int{0}
}
func (x *Survey) GetPrompt() string {
......@@ -143,9 +78,9 @@ func (x *Survey) GetFreeformPrompt() string {
return ""
}
func (x *Survey) GetRatingOptions() *RatingOptions {
func (x *Survey) GetRatingLabels() *v1.RatingLabels {
if x != nil {
return x.RatingOptions
return x.RatingLabels
}
return nil
}
......@@ -162,7 +97,7 @@ type SurveyOrigin struct {
func (x *SurveyOrigin) Reset() {
*x = SurveyOrigin{}
if protoimpl.UnsafeEnabled {
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[2]
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
......@@ -175,7 +110,7 @@ func (x *SurveyOrigin) String() string {
func (*SurveyOrigin) ProtoMessage() {}
func (x *SurveyOrigin) ProtoReflect() protoreflect.Message {
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[2]
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
......@@ -188,7 +123,7 @@ func (x *SurveyOrigin) ProtoReflect() protoreflect.Message {
// Deprecated: Use SurveyOrigin.ProtoReflect.Descriptor instead.
func (*SurveyOrigin) Descriptor() ([]byte, []int) {
return file_config_module_feedback_v1_feedback_proto_rawDescGZIP(), []int{2}
return file_config_module_feedback_v1_feedback_proto_rawDescGZIP(), []int{1}
}
func (x *SurveyOrigin) GetOrigin() v1.Origin {
......@@ -216,7 +151,7 @@ type Config struct {
func (x *Config) Reset() {
*x = Config{}
if protoimpl.UnsafeEnabled {
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[3]
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
......@@ -229,7 +164,7 @@ func (x *Config) String() string {
func (*Config) ProtoMessage() {}
func (x *Config) ProtoReflect() protoreflect.Message {
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[3]
mi := &file_config_module_feedback_v1_feedback_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
......@@ -242,7 +177,7 @@ func (x *Config) ProtoReflect() protoreflect.Message {
// Deprecated: Use Config.ProtoReflect.Descriptor instead.
func (*Config) Descriptor() ([]byte, []int) {
return file_config_module_feedback_v1_feedback_proto_rawDescGZIP(), []int{3}
return file_config_module_feedback_v1_feedback_proto_rawDescGZIP(), []int{2}
}
func (x *Config) GetOrigins() []*SurveyOrigin {
......@@ -263,45 +198,38 @@ var file_config_module_feedback_v1_feedback_proto_rawDesc = []byte{
0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1a, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x2f,
0x76, 0x31, 0x2f, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x22, 0x64, 0x0a, 0x0d, 0x52, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x74, 0x69, 0x6f,
0x6e, 0x73, 0x12, 0x19, 0x0a, 0x03, 0x6f, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42,
0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x20, 0x01, 0x52, 0x03, 0x6f, 0x6e, 0x65, 0x12, 0x19, 0x0a,
0x03, 0x74, 0x77, 0x6f, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72,
0x02, 0x20, 0x01, 0x52, 0x03, 0x74, 0x77, 0x6f, 0x12, 0x1d, 0x0a, 0x05, 0x74, 0x68, 0x72, 0x65,
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x20, 0x01,
0x52, 0x05, 0x74, 0x68, 0x72, 0x65, 0x65, 0x22, 0xb4, 0x01, 0x0a, 0x06, 0x53, 0x75, 0x72, 0x76,
0x65, 0x79, 0x12, 0x1f, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x20, 0x01, 0x52, 0x06, 0x70, 0x72, 0x6f,
0x6d, 0x70, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x66, 0x72, 0x65, 0x65, 0x66, 0x6f, 0x72, 0x6d, 0x5f,
0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x66, 0x72,
0x65, 0x65, 0x66, 0x6f, 0x72, 0x6d, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x60, 0x0a, 0x0e,
0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x63, 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2e, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x2e, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x2e, 0x66, 0x65, 0x65, 0x64,
0x62, 0x61, 0x63, 0x6b, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x4f, 0x70,
0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52,
0x0d, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x9a,
0x01, 0x0a, 0x0c, 0x53, 0x75, 0x72, 0x76, 0x65, 0x79, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12,
0x3e, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32,
0x1a, 0x2e, 0x63, 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2e, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63,
0x6b, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x42, 0x0a, 0xfa, 0x42, 0x07,
0x82, 0x01, 0x04, 0x10, 0x01, 0x20, 0x00, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12,
0x4a, 0x0a, 0x06, 0x73, 0x75, 0x72, 0x76, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x28, 0x2e, 0x63, 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e,
0x6f, 0x22, 0xa3, 0x01, 0x0a, 0x06, 0x53, 0x75, 0x72, 0x76, 0x65, 0x79, 0x12, 0x1f, 0x0a, 0x06,
0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42,
0x04, 0x72, 0x02, 0x20, 0x01, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x27, 0x0a,
0x0f, 0x66, 0x72, 0x65, 0x65, 0x66, 0x6f, 0x72, 0x6d, 0x5f, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x66, 0x72, 0x65, 0x65, 0x66, 0x6f, 0x72, 0x6d,
0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x4f, 0x0a, 0x0d, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67,
0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e,
0x63, 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2e, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x2e,
0x76, 0x31, 0x2e, 0x52, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x42,
0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x0c, 0x72, 0x61, 0x74, 0x69, 0x6e,
0x67, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x22, 0x9a, 0x01, 0x0a, 0x0c, 0x53, 0x75, 0x72, 0x76,
0x65, 0x79, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12, 0x3e, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67,
0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x63, 0x6c, 0x75, 0x74, 0x63,
0x68, 0x2e, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72,
0x69, 0x67, 0x69, 0x6e, 0x42, 0x0a, 0xfa, 0x42, 0x07, 0x82, 0x01, 0x04, 0x10, 0x01, 0x20, 0x00,
0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12, 0x4a, 0x0a, 0x06, 0x73, 0x75, 0x72, 0x76,
0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x6c, 0x75, 0x74, 0x63,
0x68, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x2e,
0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x72, 0x76,
0x65, 0x79, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x06, 0x73, 0x75,
0x72, 0x76, 0x65, 0x79, 0x22, 0x5c, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x52,
0x0a, 0x07, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x2e, 0x2e, 0x63, 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e,
0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x2e, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x2e,
0x76, 0x31, 0x2e, 0x53, 0x75, 0x72, 0x76, 0x65, 0x79, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01,
0x02, 0x10, 0x01, 0x52, 0x06, 0x73, 0x75, 0x72, 0x76, 0x65, 0x79, 0x22, 0x5c, 0x0a, 0x06, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x52, 0x0a, 0x07, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x73,
0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x63, 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2e,
0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x2e, 0x66, 0x65,
0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x72, 0x76, 0x65, 0x79,
0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x92, 0x01, 0x02, 0x08, 0x01,
0x52, 0x07, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x73, 0x42, 0x4c, 0x5a, 0x4a, 0x67, 0x69, 0x74,
0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x79, 0x66, 0x74, 0x2f, 0x63, 0x6c, 0x75,
0x74, 0x63, 0x68, 0x2f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2f, 0x61, 0x70, 0x69, 0x2f,
0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x2f, 0x66, 0x65,
0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x2f, 0x76, 0x31, 0x3b, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61,
0x63, 0x6b, 0x6d, 0x6f, 0x64, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x76, 0x31, 0x2e, 0x53, 0x75, 0x72, 0x76, 0x65, 0x79, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x42,
0x08, 0xfa, 0x42, 0x05, 0x92, 0x01, 0x02, 0x08, 0x01, 0x52, 0x07, 0x6f, 0x72, 0x69, 0x67, 0x69,
0x6e, 0x73, 0x42, 0x4c, 0x5a, 0x4a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
0x2f, 0x6c, 0x79, 0x66, 0x74, 0x2f, 0x63, 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2f, 0x62, 0x61, 0x63,
0x6b, 0x65, 0x6e, 0x64, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f,
0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x2f, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x2f,
0x76, 0x31, 0x3b, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x6d, 0x6f, 0x64, 0x76, 0x31,
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
......@@ -316,19 +244,19 @@ func file_config_module_feedback_v1_feedback_proto_rawDescGZIP() []byte {
return file_config_module_feedback_v1_feedback_proto_rawDescData
}
var file_config_module_feedback_v1_feedback_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_config_module_feedback_v1_feedback_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_config_module_feedback_v1_feedback_proto_goTypes = []interface{}{
(*RatingOptions)(nil), // 0: clutch.config.module.feedback.v1.RatingOptions
(*Survey)(nil), // 1: clutch.config.module.feedback.v1.Survey
(*SurveyOrigin)(nil), // 2: clutch.config.module.feedback.v1.SurveyOrigin
(*Config)(nil), // 3: clutch.config.module.feedback.v1.Config
(v1.Origin)(0), // 4: clutch.feedback.v1.Origin
(*Survey)(nil), // 0: clutch.config.module.feedback.v1.Survey
(*SurveyOrigin)(nil), // 1: clutch.config.module.feedback.v1.SurveyOrigin
(*Config)(nil), // 2: clutch.config.module.feedback.v1.Config
(*v1.RatingLabels)(nil), // 3: clutch.feedback.v1.RatingLabels
(v1.Origin)(0), // 4: clutch.feedback.v1.Origin
}
var file_config_module_feedback_v1_feedback_proto_depIdxs = []int32{
0, // 0: clutch.config.module.feedback.v1.Survey.rating_options:type_name -> clutch.config.module.feedback.v1.RatingOptions
3, // 0: clutch.config.module.feedback.v1.Survey.rating_labels:type_name -> clutch.feedback.v1.RatingLabels
4, // 1: clutch.config.module.feedback.v1.SurveyOrigin.origin:type_name -> clutch.feedback.v1.Origin
1, // 2: clutch.config.module.feedback.v1.SurveyOrigin.survey:type_name -> clutch.config.module.feedback.v1.Survey
2, // 3: clutch.config.module.feedback.v1.Config.origins:type_name -> clutch.config.module.feedback.v1.SurveyOrigin
0, // 2: clutch.config.module.feedback.v1.SurveyOrigin.survey:type_name -> clutch.config.module.feedback.v1.Survey
1, // 3: clutch.config.module.feedback.v1.Config.origins:type_name -> clutch.config.module.feedback.v1.SurveyOrigin
4, // [4:4] is the sub-list for method output_type
4, // [4:4] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
......@@ -343,18 +271,6 @@ func file_config_module_feedback_v1_feedback_proto_init() {
}
if !protoimpl.UnsafeEnabled {
file_config_module_feedback_v1_feedback_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*RatingOptions); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_config_module_feedback_v1_feedback_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*Survey); i {
case 0:
return &v.state
......@@ -366,7 +282,7 @@ func file_config_module_feedback_v1_feedback_proto_init() {
return nil
}
}
file_config_module_feedback_v1_feedback_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
file_config_module_feedback_v1_feedback_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*SurveyOrigin); i {
case 0:
return &v.state
......@@ -378,7 +294,7 @@ func file_config_module_feedback_v1_feedback_proto_init() {
return nil
}
}
file_config_module_feedback_v1_feedback_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
file_config_module_feedback_v1_feedback_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*Config); i {
case 0:
return &v.state
......@@ -397,7 +313,7 @@ func file_config_module_feedback_v1_feedback_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_config_module_feedback_v1_feedback_proto_rawDesc,
NumEnums: 0,
NumMessages: 4,
NumMessages: 3,
NumExtensions: 0,
NumServices: 0,
},
......
......@@ -39,138 +39,6 @@ var (
_ = feedbackv1.Origin(0)
)
// Validate checks the field values on RatingOptions with the rules defined in
// the proto definition for this message. If any rules are violated, the first
// error encountered is returned, or nil if there are no violations.
func (m *RatingOptions) Validate() error {
return m.validate(false)
}
// ValidateAll checks the field values on RatingOptions with the rules defined
// in the proto definition for this message. If any rules are violated, the
// result is a list of violation errors wrapped in RatingOptionsMultiError, or
// nil if none found.
func (m *RatingOptions) ValidateAll() error {
return m.validate(true)
}
func (m *RatingOptions) validate(all bool) error {
if m == nil {
return nil
}
var errors []error
if len(m.GetOne()) < 1 {
err := RatingOptionsValidationError{
field: "One",
reason: "value length must be at least 1 bytes",
}
if !all {
return err
}
errors = append(errors, err)
}
if len(m.GetTwo()) < 1 {
err := RatingOptionsValidationError{
field: "Two",
reason: "value length must be at least 1 bytes",
}
if !all {
return err
}
errors = append(errors, err)
}
if len(m.GetThree()) < 1 {
err := RatingOptionsValidationError{
field: "Three",
reason: "value length must be at least 1 bytes",
}
if !all {
return err
}
errors = append(errors, err)
}
if len(errors) > 0 {
return RatingOptionsMultiError(errors)
}
return nil
}
// RatingOptionsMultiError is an error wrapping multiple validation errors
// returned by RatingOptions.ValidateAll() if the designated constraints
// aren't met.
type RatingOptionsMultiError []error
// Error returns a concatenation of all the error messages it wraps.
func (m RatingOptionsMultiError) Error() string {
var msgs []string
for _, err := range m {
msgs = append(msgs, err.Error())
}
return strings.Join(msgs, "; ")
}
// AllErrors returns a list of validation violation errors.
func (m RatingOptionsMultiError) AllErrors() []error { return m }
// RatingOptionsValidationError is the validation error returned by
// RatingOptions.Validate if the designated constraints aren't met.
type RatingOptionsValidationError struct {
field string
reason string
cause error
key bool
}
// Field function returns field value.
func (e RatingOptionsValidationError) Field() string { return e.field }
// Reason function returns reason value.
func (e RatingOptionsValidationError) Reason() string { return e.reason }
// Cause function returns cause value.
func (e RatingOptionsValidationError) Cause() error { return e.cause }
// Key function returns key value.
func (e RatingOptionsValidationError) Key() bool { return e.key }
// ErrorName returns error name.
func (e RatingOptionsValidationError) ErrorName() string { return "RatingOptionsValidationError" }
// Error satisfies the builtin error interface
func (e RatingOptionsValidationError) Error() string {
cause := ""
if e.cause != nil {
cause = fmt.Sprintf(" | caused by: %v", e.cause)
}
key := ""
if e.key {
key = "key for "
}
return fmt.Sprintf(
"invalid %sRatingOptions.%s: %s%s",
key,
e.field,
e.reason,
cause)
}
var _ error = RatingOptionsValidationError{}
var _ interface {
Field() string
Reason() string
Key() bool
Cause() error
ErrorName() string
} = RatingOptionsValidationError{}
// Validate checks the field values on Survey with the rules defined in the
// proto definition for this message. If any rules are violated, the first
// error encountered is returned, or nil if there are no violations.
......@@ -205,9 +73,9 @@ func (m *Survey) validate(all bool) error {
// no validation rules for FreeformPrompt
if m.GetRatingOptions() == nil {
if m.GetRatingLabels() == nil {
err := SurveyValidationError{
field: "RatingOptions",
field: "RatingLabels",
reason: "value is required",
}
if !all {
......@@ -217,11 +85,11 @@ func (m *Survey) validate(all bool) error {
}
if all {
switch v := interface{}(m.GetRatingOptions()).(type) {
switch v := interface{}(m.GetRatingLabels()).(type) {
case interface{ ValidateAll() error }:
if err := v.ValidateAll(); err != nil {
errors = append(errors, SurveyValidationError{
field: "RatingOptions",
field: "RatingLabels",
reason: "embedded message failed validation",
cause: err,
})
......@@ -229,16 +97,16 @@ func (m *Survey) validate(all bool) error {
case interface{ Validate() error }:
if err := v.Validate(); err != nil {
errors = append(errors, SurveyValidationError{
field: "RatingOptions",
field: "RatingLabels",
reason: "embedded message failed validation",
cause: err,
})
}
}
} else if v, ok := interface{}(m.GetRatingOptions()).(interface{ Validate() error }); ok {
} else if v, ok := interface{}(m.GetRatingLabels()).(interface{ Validate() error }); ok {
if err := v.Validate(); err != nil {
return SurveyValidationError{
field: "RatingOptions",
field: "RatingLabels",
reason: "embedded message failed validation",
cause: err,
}
......
This diff is collapsed.
This diff is collapsed.
DROP TABLE IF EXISTS feedback;
CREATE TABLE feedback (
-- client_id: uuid generated by the client and used to identify the feedback submission
client_id text PRIMARY KEY,
submitted_at TIMESTAMP WITH TIME ZONE,
user_id text,
score smallint,
-- details: json blob of the feedback details
details JSONB,
-- metadata: json blob of info providing more context on the feedback details, such as:
-- the survey questions, the origin, whether the feedback was formally submitted, etc.
metadata JSONB
);
CREATE INDEX IF NOT EXISTS sort_submissions ON feedback (submitted_at);
CREATE INDEX IF NOT EXISTS sort_user_id ON feedback (user_id);
CREATE INDEX IF NOT EXISTS sort_score ON feedback (score);
CREATE INDEX IF NOT EXISTS details_json ON feedback USING GIN (details jsonb_path_ops);
CREATE INDEX IF NOT EXISTS metadata_json ON feedback USING GIN (metadata jsonb_path_ops);
......@@ -21,7 +21,7 @@ import (
dynamodbmod "github.com/lyft/clutch/backend/module/dynamodb"
"github.com/lyft/clutch/backend/module/envoytriage"
"github.com/lyft/clutch/backend/module/featureflag"
"github.com/lyft/clutch/backend/module/feedback"
feedbackmod "github.com/lyft/clutch/backend/module/feedback"
"github.com/lyft/clutch/backend/module/healthcheck"
k8smod "github.com/lyft/clutch/backend/module/k8s"
kinesismod "github.com/lyft/clutch/backend/module/kinesis"
......@@ -45,6 +45,7 @@ import (
"github.com/lyft/clutch/backend/service/chaos/experimentation/terminator"
pgservice "github.com/lyft/clutch/backend/service/db/postgres"
"github.com/lyft/clutch/backend/service/envoyadmin"
feedbackservice "github.com/lyft/clutch/backend/service/feedback"
"github.com/lyft/clutch/backend/service/github"
k8sservice "github.com/lyft/clutch/backend/service/k8s"
sourcegraphservice "github.com/lyft/clutch/backend/service/sourcegraph"
......@@ -69,7 +70,7 @@ var Modules = module.Factory{
envoytriage.Name: envoytriage.New,
experimentationapi.Name: experimentationapi.New,
featureflag.Name: featureflag.New,
feedback.Name: feedback.New,
feedbackmod.Name: feedbackmod.New,
healthcheck.Name: healthcheck.New,
k8smod.Name: k8smod.New,
kinesismod.Name: kinesismod.New,
......@@ -92,6 +93,7 @@ var Services = service.Factory{
bot.Name: bot.New,
envoyadmin.Name: envoyadmin.New,
experimentstore.Name: experimentstore.New,
feedbackservice.Name: feedbackservice.New,
github.Name: github.New,
k8sservice.Name: k8sservice.New,
loggingsink.Name: loggingsink.New,
......
......@@ -7,6 +7,7 @@ import (
"github.com/lyft/clutch/backend/mock/service/awsmock"
"github.com/lyft/clutch/backend/mock/service/chaos/experimentation/experimentstoremock"
"github.com/lyft/clutch/backend/mock/service/envoyadminmock"
"github.com/lyft/clutch/backend/mock/service/feedbackmock"
"github.com/lyft/clutch/backend/mock/service/githubmock"
"github.com/lyft/clutch/backend/mock/service/k8smock"
"github.com/lyft/clutch/backend/mock/service/topologymock"
......@@ -15,6 +16,7 @@ import (
"github.com/lyft/clutch/backend/service/aws"
"github.com/lyft/clutch/backend/service/chaos/experimentation/experimentstore"
"github.com/lyft/clutch/backend/service/envoyadmin"
"github.com/lyft/clutch/backend/service/feedback"
"github.com/lyft/clutch/backend/service/github"
"github.com/lyft/clutch/backend/service/k8s"
"github.com/lyft/clutch/backend/service/topology"
......@@ -25,6 +27,7 @@ var MockServiceFactory = service.Factory{
aws.Name: awsmock.NewAsService,
envoyadmin.Name: envoyadminmock.NewAsService,
experimentstore.Name: experimentstoremock.NewMock,
feedback.Name: feedbackmock.NewAsService,
github.Name: githubmock.NewAsService,
k8s.Name: k8smock.NewAsService,
topology.Name: topologymock.NewAsService,
......
package feedbackmock
import (
"context"
"github.com/golang/protobuf/ptypes/any"
"github.com/uber-go/tally"
"go.uber.org/zap"
feedbackv1 "github.com/lyft/clutch/backend/api/feedback/v1"
"github.com/lyft/clutch/backend/service"
"github.com/lyft/clutch/backend/service/feedback"
)
type svc struct{}
func New() feedback.Service {
return &svc{}
}
func NewAsService(*any.Any, *zap.Logger, tally.Scope) (service.Service, error) {
return New(), nil
}
// TODO: add error handling scenarios for mock testing
func (s svc) SubmitFeedback(ctx context.Context, id string, userId string, feedback *feedbackv1.Feedback, metadata *feedbackv1.FeedbackMetadata) error {
return nil
}
package feedback
// <!-- START clutchdoc -->
// description: Exposes endpoints to return survey questions for feedback components and to submit feedback submissions.
// <!-- END clutchdoc -->
import (
"context"
"errors"
"github.com/golang/protobuf/ptypes/any"
"github.com/uber-go/tally"
......@@ -12,6 +17,8 @@ import (
feedbackv1cfg "github.com/lyft/clutch/backend/api/config/module/feedback/v1"
feedbackv1 "github.com/lyft/clutch/backend/api/feedback/v1"
"github.com/lyft/clutch/backend/module"
"github.com/lyft/clutch/backend/service"
"github.com/lyft/clutch/backend/service/feedback"
)
const (
......@@ -25,8 +32,19 @@ func New(cfg *any.Any, log *zap.Logger, scope tally.Scope) (module.Module, error
return nil, err
}
feedbackClient, ok := service.Registry["clutch.service.feedback"]
if !ok {
return nil, errors.New("could not find service")
}
c, ok := feedbackClient.(feedback.Service)
if !ok {
return nil, errors.New("service was not the correct type")
}
m := &mod{
surveyMap: newSurveyLookup(config.Origins),
client: c,
logger: log,
scope: scope,
}
......@@ -43,6 +61,7 @@ type SurveyLookup struct {
type mod struct {
surveyMap SurveyLookup
client feedback.Service
logger *zap.Logger
scope tally.Scope
}
......@@ -71,11 +90,7 @@ func (m *mod) GetSurveys(tx context.Context, req *feedbackv1.GetSurveysRequest)
results[origin.String()] = &feedbackv1.Survey{
Prompt: v.Prompt,
FreeformPrompt: v.FreeformPrompt,
RatingOptions: &feedbackv1.RatingOptions{
One: v.RatingOptions.One,
Two: v.RatingOptions.Two,
Three: v.RatingOptions.Three,
},
RatingLabels: v.RatingLabels,
}
}
......@@ -123,6 +138,10 @@ func (sl SurveyLookup) getConfigSurveys(origin feedbackv1.Origin) (*feedbackv1cf
return v.Survey, true
}
func (m *mod) SubmitFeedback(tx context.Context, req *feedbackv1.SubmitFeedbackRequest) (*feedbackv1.SubmitFeedbackResponse, error) {
return nil, status.Error(codes.Unimplemented, "not implemented")
func (m *mod) SubmitFeedback(ctx context.Context, req *feedbackv1.SubmitFeedbackRequest) (*feedbackv1.SubmitFeedbackResponse, error) {
if err := m.client.SubmitFeedback(ctx, req.Id, req.UserId, req.Feedback, req.Metadata); err != nil {
m.logger.Error("failed to submit feedback", zap.Error(err))
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &feedbackv1.SubmitFeedbackResponse{}, nil
}
......@@ -10,10 +10,14 @@ import (
feedbackv1cfg "github.com/lyft/clutch/backend/api/config/module/feedback/v1"
feedbackv1 "github.com/lyft/clutch/backend/api/feedback/v1"
"github.com/lyft/clutch/backend/mock/service/feedbackmock"
"github.com/lyft/clutch/backend/module/moduletest"
"github.com/lyft/clutch/backend/service"
)
func TestModule(t *testing.T) {
service.Registry["clutch.service.feedback"] = feedbackmock.New()
config, _ := anypb.New(&feedbackv1cfg.Config{})
log := zaptest.NewLogger(t)
scope := tally.NewTestScope("", nil)
......
package feedback
// <!-- START clutchdoc -->
// description: Stores feedback submissions in the database.
// <!-- END clutchdoc -->
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/uber-go/tally"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/anypb"
feedbackv1 "github.com/lyft/clutch/backend/api/feedback/v1"
"github.com/lyft/clutch/backend/service"
pgservice "github.com/lyft/clutch/backend/service/db/postgres"
)
const (
Name = "clutch.service.feedback"
)
func New(_ *anypb.Any, logger *zap.Logger, scope tally.Scope) (service.Service, error) {
p, ok := service.Registry[pgservice.Name]
if !ok {
return nil, fmt.Errorf("could not find the %v database service", pgservice.Name)
}
dbClient, ok := p.(pgservice.Client)
if !ok {
return nil, errors.New("database does not implement the required interface")
}
return &svc{storage: &storage{db: dbClient.DB()}, logger: logger, scope: scope}, nil
}
type svc struct {
storage *storage
logger *zap.Logger
scope tally.Scope
}
type storage struct {
db *sql.DB
}
type submission struct {
id string
submittedAt time.Time
userId string
score int64
feedback *feedbackv1.Feedback
metadata *feedbackv1.FeedbackMetadata
}
// used to normalize a given emoji rating out of a 100
// can add more emoji values as new use cases arise
var emojiRatingScore = map[string]int64{
feedbackv1.EmojiRating_SAD.String(): 30,
feedbackv1.EmojiRating_NEUTRAL.String(): 70,
feedbackv1.EmojiRating_HAPPY.String(): 100,
}
type Service interface {
SubmitFeedback(ctx context.Context, id string, userId string, feedback *feedbackv1.Feedback, metadata *feedbackv1.FeedbackMetadata) error
}
func (s *svc) processSubmission(id string, userId string, feedback *feedbackv1.Feedback, metadata *feedbackv1.FeedbackMetadata) (*submission, error) {
if len(id) < 36 {
return nil, status.Error(codes.InvalidArgument, "client id was not a uuid length")
}
if userId == "" {
return nil, status.Error(codes.InvalidArgument, "user id was empty")
}
if feedback == nil || metadata == nil {
return nil, status.Errorf(codes.InvalidArgument, "feedback: %v or metadata: %v provided was nil", feedback, metadata)
}
score, err := calculateRatingScore(feedback.RatingScale)
if err != nil {
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
// the main question that was asked in the feedback component
if metadata.Survey.Prompt == "" {
return nil, status.Error(codes.InvalidArgument, "metadata survey prompt was empty")
}
if metadata.Survey.RatingLabels == nil {
return nil, status.Error(codes.InvalidArgument, "metadata rating options was nil")
}
return &submission{
id: id,
submittedAt: time.Now(),
userId: userId,
score: score,
feedback: feedback,
metadata: metadata,
}, nil
}
// looks up the enum's rating score using its corresponding rating scale
func calculateRatingScore(scale *feedbackv1.RatingScale) (int64, error) {
switch scale.Type.(type) {
case *feedbackv1.RatingScale_Emoji:
rating := scale.GetEmoji().String()
v, ok := emojiRatingScore[scale.GetEmoji().String()]
if !ok {
return -1, fmt.Errorf("unsupported rating: %v", rating)
}
return v, nil
default:
return -1, fmt.Errorf("unsupported rating scale: %v", scale)
}
}
func (s *svc) SubmitFeedback(ctx context.Context, id string, userId string, feedback *feedbackv1.Feedback, metadata *feedbackv1.FeedbackMetadata) error {
feedbackSubmission, err := s.processSubmission(id, userId, feedback, metadata)
if err != nil {
return err
}
return s.storage.createOrUpdateSubmission(ctx, feedbackSubmission)
}
package feedback
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/uber-go/tally"
"go.uber.org/zap/zaptest"
feedbackv1 "github.com/lyft/clutch/backend/api/feedback/v1"
)
func TestProcessSubmission(t *testing.T) {
id := "00000000-0000-0000-0000-000000000000"
userId := "foo@example.com"
validFeedbackTestCase := &feedbackv1.Feedback{UrlPath: "/k8s/deletePod", RatingLabel: "great", RatingScale: &feedbackv1.RatingScale{
Type: &feedbackv1.RatingScale_Emoji{Emoji: feedbackv1.EmojiRating_HAPPY},
}}
vailidMetadataTestCase := &feedbackv1.FeedbackMetadata{
Origin: feedbackv1.Origin_WIZARD,
Survey: &feedbackv1.Survey{
Prompt: "Rate your experience",
RatingLabels: &feedbackv1.RatingLabels{Type: &feedbackv1.RatingLabels_Emoji{Emoji: &feedbackv1.EmojiRatingLabels{Sad: "bad", Neutral: "ok", Happy: "great"}}},
},
UserSubmitted: true,
}
testErrorCases := []struct {
id string
userId string
feedback *feedbackv1.Feedback
metadata *feedbackv1.FeedbackMetadata
}{
// feedback is nil
{
id: id,
userId: userId,
feedback: nil,
metadata: vailidMetadataTestCase,
},
// id is not the required length
{
id: "00000000-0000-0000-0000",
userId: userId,
feedback: validFeedbackTestCase,
metadata: vailidMetadataTestCase,
},
// userId is empty
{
id: id,
userId: "",
feedback: &feedbackv1.Feedback{UrlPath: "/k8s/deletePod", RatingLabel: "great"},
metadata: vailidMetadataTestCase,
},
// metadata is nil
{
id: id,
userId: userId,
feedback: validFeedbackTestCase,
metadata: nil,
},
// metadata survey prompt is empty
{
id: id,
userId: userId,
feedback: validFeedbackTestCase,
metadata: &feedbackv1.FeedbackMetadata{
Origin: feedbackv1.Origin_WIZARD,
Survey: &feedbackv1.Survey{
Prompt: "",
RatingLabels: &feedbackv1.RatingLabels{Type: &feedbackv1.RatingLabels_Emoji{Emoji: &feedbackv1.EmojiRatingLabels{Sad: "bad", Neutral: "ok", Happy: "great"}}},
}, UserSubmitted: true},
},
// metadata rating options is nil
{
id: id,
userId: userId,
feedback: validFeedbackTestCase,
metadata: &feedbackv1.FeedbackMetadata{
Origin: feedbackv1.Origin_WIZARD,
Survey: &feedbackv1.Survey{
Prompt: "Rate your experience",
RatingLabels: nil,
},
UserSubmitted: true,
},
},
}
logger := zaptest.NewLogger(t)
scope := tally.NewTestScope("", nil)
storage := &storage{}
s := &svc{storage, logger, scope}
// happy path
submission, err := s.processSubmission(id, userId, validFeedbackTestCase, vailidMetadataTestCase)
assert.NoError(t, err)
assert.NotNil(t, submission)
// error scenarios
for _, test := range testErrorCases {
submission, err := s.processSubmission(test.id, test.userId, test.feedback, test.metadata)
assert.Error(t, err)
assert.Nil(t, submission)
}
}
func TestCalculateRatingScore(t *testing.T) {
testCases := []struct {
scale *feedbackv1.RatingScale
expectedScore int64
expectedErr bool
}{
{
scale: &feedbackv1.RatingScale{Type: &feedbackv1.RatingScale_Emoji{Emoji: feedbackv1.EmojiRating_SAD}},
expectedScore: 30,
},
{
scale: &feedbackv1.RatingScale{Type: &feedbackv1.RatingScale_Emoji{Emoji: feedbackv1.EmojiRating_NEUTRAL}},
expectedScore: 70,
},
{
scale: &feedbackv1.RatingScale{Type: &feedbackv1.RatingScale_Emoji{Emoji: feedbackv1.EmojiRating_HAPPY}},
expectedScore: 100,
},
{
scale: &feedbackv1.RatingScale{Type: &feedbackv1.RatingScale_Emoji{Emoji: feedbackv1.EmojiRating_EMOJI_UNSPECIFIED}},
expectedScore: -1,
expectedErr: true,
},
}
for _, test := range testCases {
result, err := calculateRatingScore(test.scale)
if test.expectedErr {
assert.Error(t, err)
assert.Equal(t, test.expectedScore, result)
} else {
assert.NoError(t, err)
assert.Equal(t, test.expectedScore, result)
}
}
}
package feedback
import (
"context"
"google.golang.org/protobuf/encoding/protojson"
)
const createOrUpdateSubmissionQuery = `
INSERT INTO feedback (client_id, submitted_at, user_id, score, details, metadata) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (client_id) DO UPDATE SET
client_id = EXCLUDED.client_id,
submitted_at = EXCLUDED.submitted_at,
user_id = EXCLUDED.user_id,
score = EXCLUDED.score,
details = EXCLUDED.details,
metadata = EXCLUDED.metadata
`
func (s *storage) createOrUpdateSubmission(ctx context.Context, submission *submission) error {
feedbackJSON, err := protojson.Marshal(submission.feedback)
if err != nil {
return err
}
metadataJSON, err := protojson.Marshal(submission.metadata)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, createOrUpdateSubmissionQuery, submission.id, submission.submittedAt, submission.userId, submission.score, feedbackJSON, metadataJSON)
return err
}
package feedback
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/encoding/protojson"
feedbackv1 "github.com/lyft/clutch/backend/api/feedback/v1"
)
func TestCreateOrUpdateSubmission(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
assert.NoError(t, err)
defer db.Close()
testCases := []struct {
feedback *feedbackv1.Feedback
metadata *feedbackv1.FeedbackMetadata
}{
{
feedback: &feedbackv1.Feedback{RatingLabel: "great"},
metadata: &feedbackv1.FeedbackMetadata{UserSubmitted: true},
},
// a sanity check that nil values aren't going to raise errors
{
feedback: nil,
metadata: nil,
},
}
for _, test := range testCases {
s := &submission{
id: "00000000-0000-0000-0000-000000000000",
userId: "foo@example.com",
score: 70,
submittedAt: time.Now(),
feedback: test.feedback,
metadata: test.metadata,
}
feedbackJSON, err := protojson.Marshal(s.feedback)
assert.NoError(t, err)
metadataJSON, err := protojson.Marshal(s.metadata)
assert.NoError(t, err)
mock.ExpectExec(createOrUpdateSubmissionQuery).
WithArgs(s.id, s.submittedAt, s.userId, s.score, feedbackJSON, metadataJSON).
WillReturnResult(sqlmock.NewResult(0, 1))
r := &storage{db: db}
err = r.createOrUpdateSubmission(context.Background(), s)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
}
}
This diff is collapsed.
This diff is collapsed.
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment