diff --git a/api/go.sum b/api/go.sum index 17a51a6..ef2a0e3 100644 --- a/api/go.sum +++ b/api/go.sum @@ -70,18 +70,11 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/api/migrations/postgres/000002_webhooks.down.sql b/api/migrations/postgres/000002_webhooks.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/api/migrations/postgres/000002_webhooks.up.sql b/api/migrations/postgres/000002_webhooks.up.sql new file mode 100644 index 0000000..54a33b8 --- /dev/null +++ b/api/migrations/postgres/000002_webhooks.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE + surveys_webhook_responses ( + id serial NOT NULL PRIMARY KEY, + created_at timestamp without time zone default (now () at time zone 'utc'), + session_id integer NOT NULL, + response_status integer NOT NULL, + response TEXT, + CONSTRAINT fk_surveys_webhooks1 FOREIGN KEY (session_id) REFERENCES surveys_sessions (id) ON DELETE CASCADE, + ); \ No newline at end of file diff --git a/api/migrations/sqlite/000002_webhooks.down.sql b/api/migrations/sqlite/000002_webhooks.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/api/migrations/sqlite/000002_webhooks.up.sql b/api/migrations/sqlite/000002_webhooks.up.sql new file mode 100644 index 0000000..0f1470d --- /dev/null +++ b/api/migrations/sqlite/000002_webhooks.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE surveys_webhook_responses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT, + session_id INTEGER NOT NULL, + response_status INTEGER NOT NULL, + response TEXT, + FOREIGN KEY (session_id) REFERENCES surveys_sessions (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/api/pkg/controllers/survey_sessions.go b/api/pkg/controllers/survey_sessions.go index dae3ede..2b21a33 100644 --- a/api/pkg/controllers/survey_sessions.go +++ b/api/pkg/controllers/survey_sessions.go @@ -10,6 +10,7 @@ import ( "github.com/labstack/echo/v4" "github.com/plutov/formulosity/api/pkg/http/response" + "github.com/plutov/formulosity/api/pkg/surveys" surveyspkg "github.com/plutov/formulosity/api/pkg/surveys" "github.com/plutov/formulosity/api/pkg/types" @@ -102,6 +103,14 @@ func (h *Handler) submitSurveyAnswer(c echo.Context) error { return response.NotFound(c, err.Error()) } + if session.Status == types.SurveySessionStatus_Completed { + go func() { + if err := surveyspkg.CallWebhook(h.Services, survey, session); err != nil { + log.Println("call webhook error:", err) + } + }() + } + return response.Ok(c, *session) } @@ -150,16 +159,16 @@ func (h *Handler) getUploadedFile(c echo.Context, req []byte) (*types.File, erro return nil, errors.New("file not provided") } fileName := header.Filename - fileExt := strings.ToLower(filepath.Ext(fileName)) + fileExt := strings.ToLower(filepath.Ext(fileName)) defer file.Close() uploadedFile = &types.File{ - Name: header.Filename, - Data: file, - Size: header.Size, + Name: header.Filename, + Data: file, + Size: header.Size, Format: fileExt, } } return uploadedFile, nil -} \ No newline at end of file +} diff --git a/api/pkg/storage/interface.go b/api/pkg/storage/interface.go index b08e59b..d1b89c3 100644 --- a/api/pkg/storage/interface.go +++ b/api/pkg/storage/interface.go @@ -22,10 +22,12 @@ type Interface interface { GetSurveySessionsWithAnswers(surveyUUID string, filter *types.SurveySessionsFilter) ([]types.SurveySession, int, error) GetSurveySessionAnswers(sessionUUID string) ([]types.QuestionAnswer, error) UpsertSurveyQuestionAnswer(sessionUUID string, questionUUID string, answer types.Answer) error + + StoreWebhookResponse(sessionId int, responseStatus int, response string) error } type FileInterface interface { Init() error SaveFile(file *types.File) (string, error) -} \ No newline at end of file +} diff --git a/api/pkg/storage/postgres.go b/api/pkg/storage/postgres.go index 64a2310..3fe2d4c 100644 --- a/api/pkg/storage/postgres.go +++ b/api/pkg/storage/postgres.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/golang-migrate/migrate/v4" migratepg "github.com/golang-migrate/migrate/v4/database/postgres" @@ -347,11 +348,12 @@ func (p *Postgres) GetSurveySessionsWithAnswers(surveyUUID string, filter *types LIMIT %d OFFSET %d ) SELECT - ss.id, ss.uuid, ss.created_at, ss.completed_at, ss.status, q.id, q.uuid, sa.answer + ss.id, ss.uuid, ss.created_at, ss.completed_at, ss.status, q.id, q.uuid, sa.answer, w.response_status, w.response FROM limited_sessions AS ss INNER JOIN surveys AS s ON s.id = ss.survey_id LEFT JOIN surveys_answers AS sa ON sa.session_id = ss.id LEFT JOIN surveys_questions AS q ON q.id = sa.question_id + LEFT JOIN surveys_webhook_responses AS w ON w.session_id = ss.id WHERE s.uuid=$1 ORDER BY ss.%s %s ;`, filter.SortBy, filter.Order, filter.Limit, filter.Offset, filter.SortBy, filter.Order) @@ -365,17 +367,20 @@ func (p *Postgres) GetSurveySessionsWithAnswers(surveyUUID string, filter *types sessionsMap := map[string]types.SurveySession{} for rows.Next() { session := types.SurveySession{} + webhookData := types.WebhookData{} answer := types.QuestionAnswer{} var ( questionID sql.NullString questionUUID sql.NullString ) - err := rows.Scan(&session.ID, &session.UUID, &session.CreatedAt, &session.CompletedAt, &session.Status, &questionID, &questionUUID, &answer.AnswerBytes) + err := rows.Scan(&session.ID, &session.UUID, &session.CreatedAt, &session.CompletedAt, &session.Status, &questionID, &questionUUID, &answer.AnswerBytes, &webhookData.StatusCode, &webhookData.Response) if err != nil { return nil, 0, err } + session.WebhookData = webhookData + if _, ok := sessionsMap[session.UUID]; !ok { session.QuestionAnswers = []types.QuestionAnswer{} sessionsMap[session.UUID] = session @@ -417,3 +422,13 @@ func (p *Postgres) getSurveySessionsCount(surveyUUID string) (int, error) { err := row.Scan(&count) return count, err } + +func (p *Postgres) StoreWebhookResponse(sessionId int, responseStatus int, response string) error { + query := `INSERT INTO surveys_webhook_responses + (created_at, session_id, response_status, response) + VALUES ($1, $2, $3, $4);` + + createdAtStr := time.Now().UTC().Format(types.DateTimeFormat) + _, err := p.conn.Exec(query, createdAtStr, sessionId, responseStatus, response) + return err +} diff --git a/api/pkg/storage/sqlite.go b/api/pkg/storage/sqlite.go index 773c6cf..c0c91ea 100644 --- a/api/pkg/storage/sqlite.go +++ b/api/pkg/storage/sqlite.go @@ -386,11 +386,12 @@ func (p *Sqlite) GetSurveySessionsWithAnswers(surveyUUID string, filter *types.S LIMIT %d OFFSET %d ) SELECT - ss.id, ss.uuid, ss.created_at, ss.completed_at, ss.status, q.id, q.uuid, sa.answer + ss.id, ss.uuid, ss.created_at, ss.completed_at, ss.status, q.id, q.uuid, sa.answer, w.response_status, w.response FROM limited_sessions AS ss INNER JOIN surveys AS s ON s.id = ss.survey_id LEFT JOIN surveys_answers AS sa ON sa.session_id = ss.id LEFT JOIN surveys_questions AS q ON q.id = sa.question_id + LEFT JOIN surveys_webhook_responses AS w ON w.session_id = ss.id WHERE s.uuid=$1 ORDER BY ss.%s %s ;`, filter.SortBy, filter.Order, filter.Limit, filter.Offset, filter.SortBy, filter.Order) @@ -411,9 +412,11 @@ func (p *Sqlite) GetSurveySessionsWithAnswers(surveyUUID string, filter *types.S createdAtStr sql.NullString completedAtStr sql.NullString answerStr sql.NullString + httpStatusCode sql.NullInt16 + httpResponse sql.NullString ) - err := rows.Scan(&session.ID, &session.UUID, &createdAtStr, &completedAtStr, &session.Status, &questionID, &questionUUID, &answerStr) + err := rows.Scan(&session.ID, &session.UUID, &createdAtStr, &completedAtStr, &session.Status, &questionID, &questionUUID, &answerStr, &httpStatusCode, &httpResponse) if err != nil { return nil, 0, err } @@ -425,6 +428,13 @@ func (p *Sqlite) GetSurveySessionsWithAnswers(surveyUUID string, filter *types.S } answer.AnswerBytes = []byte(answerStr.String) + if httpStatusCode.Valid && httpResponse.Valid { + session.WebhookData = types.WebhookData{ + StatusCode: httpStatusCode.Int16, + Response: httpResponse.String, + } + } + if _, ok := sessionsMap[session.UUID]; !ok { session.QuestionAnswers = []types.QuestionAnswer{} sessionsMap[session.UUID] = session @@ -466,3 +476,14 @@ func (p *Sqlite) getSurveySessionsCount(surveyUUID string) (int, error) { err := row.Scan(&count) return count, err } + +func (p *Sqlite) StoreWebhookResponse(sessionId int, responseStatus int, response string) error { + query := `INSERT INTO surveys_webhook_responses + (created_at, session_id, response_status, response) + VALUES ($1, $2, $3, $4);` + + createdAtStr := time.Now().UTC().Format(types.DateTimeFormat) + + _, err := p.conn.Exec(query, createdAtStr, sessionId, responseStatus, response) + return err +} diff --git a/api/pkg/surveys/sessions.go b/api/pkg/surveys/sessions.go index 12f0339..dd7b5a8 100644 --- a/api/pkg/surveys/sessions.go +++ b/api/pkg/surveys/sessions.go @@ -1,8 +1,13 @@ package surveys import ( + "bytes" "encoding/json" "errors" + "fmt" + "io" + "net/http" + "time" "github.com/plutov/formulosity/api/pkg/log" "github.com/plutov/formulosity/api/pkg/services" @@ -105,3 +110,35 @@ func GetSurveySessions(svc services.Services, survey types.Survey, filter *types return sessions, pagesCount, nil } + +func CallWebhook(svc services.Services, survey *types.Survey, session *types.SurveySession) error { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + data, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("invalid post data, err: %v", err) + } + + req, err := http.NewRequest(survey.Config.Webhook.Method, survey.Config.Webhook.URL, bytes.NewBuffer(data)) + if err != nil { + return fmt.Errorf("invalid http request, err: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("error making request, err: %v", err) + } + defer resp.Body.Close() + + statusCode := resp.StatusCode + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + responseBody = []byte{} + } + + return svc.Storage.StoreWebhookResponse(int(session.ID), statusCode, string(responseBody)) +} diff --git a/api/pkg/types/survey.go b/api/pkg/types/survey.go index 50134a3..b53ef74 100644 --- a/api/pkg/types/survey.go +++ b/api/pkg/types/survey.go @@ -67,10 +67,11 @@ type SurveyStats struct { } type SurveyConfig struct { - Title string `json:"title" yaml:"title"` - Intro string `json:"intro" yaml:"intro"` - Outro string `json:"outro" yaml:"outro"` - Theme string `json:"theme" yaml:"theme"` + Title string `json:"title" yaml:"title"` + Intro string `json:"intro" yaml:"intro"` + Outro string `json:"outro" yaml:"outro"` + Theme string `json:"theme" yaml:"theme"` + Webhook *WebhookConfig `json:"webhook" yaml:"webhook"` Hash string `json:"hash" yaml:"-"` Questions *Questions `json:"questions" yaml:"-"` @@ -122,6 +123,12 @@ func (s *SurveyConfig) Validate() error { return err } + if s.Webhook != nil { + if err := s.Webhook.Validate(); err != nil { + return err + } + } + return nil } diff --git a/api/pkg/types/survey_session.go b/api/pkg/types/survey_session.go index cfc0943..0cb9ebd 100644 --- a/api/pkg/types/survey_session.go +++ b/api/pkg/types/survey_session.go @@ -28,6 +28,12 @@ type SurveySession struct { SurveyUUID string `json:"survey_uuid"` IPAddr string `json:"ip_addr"` QuestionAnswers []QuestionAnswer `json:"question_answers"` + WebhookData WebhookData `json:"webhookData"` +} + +type WebhookData struct { + StatusCode int16 `json:"statusCode"` + Response string `json:"response"` } type SurveySessionsFilter struct { diff --git a/api/pkg/types/webhook.go b/api/pkg/types/webhook.go new file mode 100644 index 0000000..2dc4dec --- /dev/null +++ b/api/pkg/types/webhook.go @@ -0,0 +1,33 @@ +package types + +import ( + "errors" + "net/url" + "strings" +) + +type WebhookConfig struct { + URL string `json:"url" yaml:"url"` + Method string `json:"method" yaml:"method"` +} + +func (wc *WebhookConfig) Validate() error { + parsedUrl, err := url.ParseRequestURI(wc.URL) + if err != nil { + return errors.New("webhook url format invalid") + } + + if parsedUrl.Scheme != "http" && parsedUrl.Scheme != "https" { + return errors.New("webhook scheme invalid") + } + + if parsedUrl.Host == "" { + return errors.New("webhook host invalid") + } + + if !strings.EqualFold(wc.Method, "POST") { + return errors.New("unsupported http method for webhook") + } + + return nil +} diff --git a/api/surveys/short/metadata.yaml b/api/surveys/short/metadata.yaml index eb4bec5..e159e43 100644 --- a/api/surveys/short/metadata.yaml +++ b/api/surveys/short/metadata.yaml @@ -4,3 +4,6 @@ intro: | Welcome to the survey. outro: | Thank you for taking the survey. +webhook: + url: https://formulosity.requestcatcher.com/ + method: POST \ No newline at end of file diff --git a/ui/src/components/app/SurveyResponsesPage.tsx b/ui/src/components/app/SurveyResponsesPage.tsx index 411c35b..ca9fb50 100644 --- a/ui/src/components/app/SurveyResponsesPage.tsx +++ b/ui/src/components/app/SurveyResponsesPage.tsx @@ -149,6 +149,7 @@ export function SurveyResponsesPage({ {col.label} ))} +