diff --git a/db/dev/v1_0.sql b/db/dev/v1_0.sql index 9bcee9f..51163d5 100644 --- a/db/dev/v1_0.sql +++ b/db/dev/v1_0.sql @@ -22,29 +22,36 @@ CREATE TABLE IF NOT EXISTS "holiday" references country(iso_name) on delete cascade on update cascade ); -INSERT INTO "country" (id, iso_name, name) -VALUES - ('096ca6c4-5c04-47a4-0063-4b4cc6f6b671', 'HR', 'Croatia'), - ('096ca6c4-5c04-47a4-0063-4b4cc6f6b672', 'US', 'USA'), - ('096ca6c4-5c04-47a4-0063-4b4cc6f6b673', 'FR', 'France'), - ('096ca6c4-5c04-47a4-0063-4b4cc6f6b674', 'GB', 'Great Britain'); +CREATE TABLE IF NOT EXISTS "webhook" +( + id uuid, + created timestamp NOT NULL, + url varchar(256) NOT NULL, + country char(2) NOT NULL, + retry_count int NOT NULL, + on_created bool NOT NULL, + on_edited bool NOT NULL, + on_deleted bool NOT NULL, -INSERT INTO "holiday" (id, country, date, name, description, is_state, is_religious) -VALUES - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b671', 'HR', '2023-01-01', 'Nova godina', '', true, false), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b672', 'HR', '2023-01-06', 'Sveta tri kralja', '', true, true), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b673', 'HR', '2023-04-09', 'Uskrs', '', true, true), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b674', 'HR', '2023-04-10', 'Uskrsni ponedjeljak', '', true, true), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b675', 'HR', '2023-05-01', 'Praznik rada', '', true, false), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b676', 'HR', '2023-05-30', 'Dan državnosti', '', true, false), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b677', 'HR', '2023-06-08', 'Tijelovo', '', true, true), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b678', 'HR', '2023-06-22', 'Dan antifašističke borbe', '', true, false), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b679', 'HR', '2023-08-05', 'Dan pobjede i domovinske zahvalnost', '', true, false), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b680', 'HR', '2023-08-15', 'Velika gospa', '', true, true), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b681', 'HR', '2023-11-01', 'Dan svih svetih', '', true, true), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b682', 'HR', '2023-11-18', 'Dan sjećanja na žrtve Domovinskog rata', '', true, false), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b683', 'HR', '2023-12-25', 'Božić', '', true, true), - ('096ca6c4-5c04-47a4-b363-4b4cc6f6b684', 'HR', '2023-12-26', 'Štefanje', '', true, true); + primary key (id), + constraint fk_country_id foreign key (country) + references country(iso_name) on delete cascade on update cascade +); +CREATE TABLE IF NOT EXISTS "job" +( + id uuid, + webhook_id uuid NOT NULL, + created timestamp NOT NULL, + success bool NOT NULL, + success_time timestamp, + retry_count int NOT NULL, + + content varchar(1024) NOT NULL, + + primary key (id), + constraint fk_webhook_id foreign key (webhook_id) + references webhook(id) on delete cascade on update cascade +); \ No newline at end of file diff --git a/db/dev/v1_1.sql b/db/dev/v1_1.sql new file mode 100644 index 0000000..9494a59 --- /dev/null +++ b/db/dev/v1_1.sql @@ -0,0 +1,33 @@ + +INSERT INTO "country" (id, iso_name, name) +VALUES + ('096ca6c4-5c04-47a4-0063-4b4cc6f6b671', 'HR', 'Croatia'), + ('096ca6c4-5c04-47a4-0063-4b4cc6f6b672', 'US', 'USA'), + ('096ca6c4-5c04-47a4-0063-4b4cc6f6b673', 'FR', 'France'), + ('096ca6c4-5c04-47a4-0063-4b4cc6f6b674', 'GB', 'Great Britain'); + +INSERT INTO "holiday" (id, country, date, name, description, is_state, is_religious) +VALUES + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b671', 'HR', '2023-01-01', 'Nova godina', '', true, false), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b672', 'HR', '2023-01-06', 'Sveta tri kralja', '', true, true), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b673', 'HR', '2023-04-09', 'Uskrs', '', true, true), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b674', 'HR', '2023-04-10', 'Uskrsni ponedjeljak', '', true, true), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b675', 'HR', '2023-05-01', 'Praznik rada', '', true, false), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b676', 'HR', '2023-05-30', 'Dan državnosti', '', true, false), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b677', 'HR', '2023-06-08', 'Tijelovo', '', true, true), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b678', 'HR', '2023-06-22', 'Dan antifašističke borbe', '', true, false), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b679', 'HR', '2023-08-05', 'Dan pobjede i domovinske zahvalnost', '', true, false), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b680', 'HR', '2023-08-15', 'Velika gospa', '', true, true), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b681', 'HR', '2023-11-01', 'Dan svih svetih', '', true, true), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b682', 'HR', '2023-11-18', 'Dan sjećanja na žrtve Domovinskog rata', '', true, false), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b683', 'HR', '2023-12-25', 'Božić', '', true, true), + ('096ca6c4-5c04-47a4-b363-4b4cc6f6b684', 'HR', '2023-12-26', 'Štefanje', '', true, true); + +INSERT INTO "webhook" (id, created, url, country, retry_count, on_created, on_edited, on_deleted) +VALUES + ('b0b0a6c4-5c04-47a4-b363-4b4cc6f6b671', current_timestamp, 'https://hr.bbr-dev.info', 'HR', 10, true, true, true), + ('b0b0a6c4-5c04-47a4-b363-4b4cc6f6b672', current_timestamp, 'https://hr2.bbr-dev.info', 'HR', 10, true, false, false); + +INSERT INTO "job" (id, webhook_id, created, success, success_time, retry_count, content) +VALUES + ('c0c0a6c4-5c04-47a4-b363-4b4cc6f6b671', 'b0b0a6c4-5c04-47a4-b363-4b4cc6f6b671', current_timestamp, false, null, 8, '{"type": "created", "holiday": {"id": "096ca6c4-5c04-47a4-b363-4b4cc6f6b671", "name": "Nova godina", "date": "2023-01-01"}}'); \ No newline at end of file diff --git a/db/prod/v1_1.sql b/db/prod/v1_1.sql new file mode 100644 index 0000000..0798199 --- /dev/null +++ b/db/prod/v1_1.sql @@ -0,0 +1,34 @@ + +CREATE TABLE IF NOT EXISTS "webhook" +( + id uuid, + created timestamp NOT NULL, + url varchar(256) NOT NULL, + country char(2) NOT NULL, + retry_count int NOT NULL, + on_created bool NOT NULL, + on_edited bool NOT NULL, + on_deleted bool NOT NULL, + + primary key (id), + constraint fk_country_id foreign key (country) + references country(iso_name) on delete cascade on update cascade +); + +CREATE TABLE IF NOT EXISTS "job" +( + id uuid, + webhook_id uuid NOT NULL, + + created timestamp NOT NULL, + success bool NOT NULL, + success_time timestamp NOT NULL, + + retry_count int NOT NULL, + + content varchar(1024) NOT NULL, + + primary key (id), + constraint fk_webhook_id foreign key (webhook_id) + references webhook(id) on delete cascade on update cascade +); \ No newline at end of file diff --git a/main.go b/main.go index 1f64b86..5b62d5f 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( _ "github.com/lib/pq" "holiday-api/holiday" "holiday-api/migration" + "holiday-api/webhook" "html/template" "log" "net/http" @@ -56,10 +57,15 @@ func main() { holidayService := holiday.HolidayService{DB: client} countryService := holiday.CountryService{DB: client} yearService := holiday.YearService{DB: client} + webhookService := webhook.WebhookService{ + DB: client, + Events: make(chan webhook.Event, 10), + Authorization: "TODO", // TODO add authorization fetching + } g.GET("/api/v1/holidays", getHolidays(holidayService)) - setupAdminDashboard(g.Group("/admin"), holidayService, countryService, yearService) + setupAdminDashboard(g.Group("/admin"), holidayService, countryService, yearService, webhookService) g.GET("/", func(c *gin.Context) { year := time.Now().Year() @@ -121,18 +127,23 @@ func loadTemplates(g *gin.Engine) { "templates/admin_dashboard.gohtml", "templates/countries.gohtml", + "templates/webhooks.gohtml", "templates/dialogs/add-holiday.gohtml", "templates/dialogs/edit-holiday.gohtml", "templates/dialogs/delete-holiday.gohtml", "templates/dialogs/check-is-a-holiday.gohtml", + "templates/dialogs/add-webhook.gohtml", + "templates/dialogs/edit-webhook.gohtml", + "templates/dialogs/delete-webhook.gohtml", + "templates/dialogs/edit-country.gohtml", "templates/dialogs/delete-country.gohtml", ) } -func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.HolidayService, countryService holiday.CountryService, yearService holiday.YearService) { +func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.HolidayService, countryService holiday.CountryService, yearService holiday.YearService, webhookService webhook.WebhookService) { adminDashboard.Use(gin.BasicAuth(loadAuth())) adminDashboard.GET("/", func(c *gin.Context) { @@ -252,10 +263,71 @@ func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.Holida c.Redirect(http.StatusSeeOther, "/admin/countries") }) + adminDashboard.GET("/webhooks", func(c *gin.Context) { + webhooks, _ := webhookService.Find() + c.HTML(http.StatusOK, "webhooks.gohtml", gin.H{"Webhooks": webhooks}) + }) + adminDashboard.POST("/webhooks", func(c *gin.Context) { + request := struct { + Id *string `form:"id"` + Url string `form:"url" binding:"required,min=1"` + RetryCount int `form:"retry_count" binding:"required,min=1,max=20"` + Country string `form:"country" binding:"len=2"` + OnCreated bool `form:"on_created"` + OnEdited bool `form:"on_edited"` + OnDeleted bool `form:"on_deleted"` + }{} + if err := c.ShouldBind(&request); err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + hook := webhook.Webhook{ + Url: request.Url, + Country: request.Country, + RetryCount: request.RetryCount, + OnCreated: request.OnCreated, + OnEdited: request.OnEdited, + OnDeleted: request.OnDeleted, + } + + var err error + if request.Id != nil { + hook.Id = uuid.MustParse(*request.Id) + hook, err = webhookService.Update(hook) + } else { + hook.Id = uuid.Must(uuid.NewRandom()) + hook.Created = time.Now() + hook, err = webhookService.Create(hook) + } + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + } else { + c.Redirect(http.StatusSeeOther, "/admin/webhooks") + } + }) + adminDashboard.POST("/webhooks/:id/delete", func(c *gin.Context) { + id := uuid.MustParse(c.Param("id")) + _, err := webhookService.FindById(id) + if err != nil { + c.AbortWithError(http.StatusNotFound, err) + return + } + if err := webhookService.Delete(id); err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.Redirect(http.StatusSeeOther, "/admin/webhooks") + }) + adminDashboard.GET("/dialogs/add-holiday", func(c *gin.Context) { countries, _ := countryService.Find() c.HTML(http.StatusOK, "add-holiday.gohtml", gin.H{"Countries": countries}) }) + adminDashboard.GET("/dialogs/add-webhook", func(c *gin.Context) { + countries, _ := countryService.Find() + c.HTML(http.StatusOK, "add-webhook.gohtml", gin.H{"Countries": countries}) + }) adminDashboard.GET("/dialogs/edit-holiday", func(c *gin.Context) { id := uuid.MustParse(c.Query("id")) hol, err := service.FindById(id) @@ -266,6 +338,16 @@ func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.Holida countries, _ := countryService.Find() c.HTML(http.StatusOK, "edit-holiday.gohtml", gin.H{"Countries": countries, "Holiday": hol}) }) + adminDashboard.GET("/dialogs/edit-webhook", func(c *gin.Context) { + id := uuid.MustParse(c.Query("id")) + hook, err := webhookService.FindById(id) + if err != nil { + c.AbortWithError(http.StatusNotFound, err) + return + } + countries, _ := countryService.Find() + c.HTML(http.StatusOK, "edit-webhook.gohtml", gin.H{"Countries": countries, "Webhook": hook}) + }) adminDashboard.GET("/dialogs/edit-country", func(c *gin.Context) { id := uuid.MustParse(c.Query("id")) country, err := countryService.FindById(id) @@ -293,6 +375,15 @@ func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.Holida } c.HTML(http.StatusOK, "delete-country.gohtml", gin.H{"Country": country}) }) + adminDashboard.GET("/dialogs/delete-webhook", func(c *gin.Context) { + id := uuid.MustParse(c.Query("id")) + hook, err := webhookService.FindById(id) + if err != nil { + c.AbortWithError(http.StatusNotFound, err) + return + } + c.HTML(http.StatusOK, "delete-webhook.gohtml", gin.H{"Webhook": hook}) + }) } func loadAuth() map[string]string { diff --git a/migration/migration.go b/migration/migration.go index 64f48de..5ac77f0 100644 --- a/migration/migration.go +++ b/migration/migration.go @@ -97,8 +97,7 @@ func executeMigration(db *sqlx.DB, name string, script string) error { var err error = nil if _, e := tx.Exec(script); e != nil { err = e - } - if _, e := tx.Exec(createMigration, name, hash(script)); e != nil { + } else if _, e := tx.Exec(createMigration, name, hash(script)); e != nil { err = e } if err != nil { diff --git a/templates/admin_dashboard.gohtml b/templates/admin_dashboard.gohtml index 06f16cb..d3692c4 100644 --- a/templates/admin_dashboard.gohtml +++ b/templates/admin_dashboard.gohtml @@ -18,6 +18,7 @@
Search Countries + Webhooks
diff --git a/templates/countries.gohtml b/templates/countries.gohtml index 6c2fe13..a82f4ef 100644 --- a/templates/countries.gohtml +++ b/templates/countries.gohtml @@ -18,6 +18,7 @@
Search Countries + Webhooks
diff --git a/templates/dialogs/add-webhook.gohtml b/templates/dialogs/add-webhook.gohtml new file mode 100644 index 0000000..da4d5fb --- /dev/null +++ b/templates/dialogs/add-webhook.gohtml @@ -0,0 +1,35 @@ + +

Create webhook

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+ +
+ + +
+
+
\ No newline at end of file diff --git a/templates/dialogs/delete-webhook.gohtml b/templates/dialogs/delete-webhook.gohtml new file mode 100644 index 0000000..ac9eed0 --- /dev/null +++ b/templates/dialogs/delete-webhook.gohtml @@ -0,0 +1,11 @@ + + +

Delete webhook

+

Are you sure you want to delete webhook to
{{.Webhook.Url}}?
All jobs for given webhook will be deleted!

+
+
+ + +
+
+
\ No newline at end of file diff --git a/templates/dialogs/edit-webhook.gohtml b/templates/dialogs/edit-webhook.gohtml new file mode 100644 index 0000000..0083d5e --- /dev/null +++ b/templates/dialogs/edit-webhook.gohtml @@ -0,0 +1,36 @@ + +

Edit webhook

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+ +
+ + +
+
+
\ No newline at end of file diff --git a/templates/webhooks.gohtml b/templates/webhooks.gohtml new file mode 100644 index 0000000..93abac1 --- /dev/null +++ b/templates/webhooks.gohtml @@ -0,0 +1,63 @@ + + + + + + Holiday-api | Admin dashboard + + + + +
+
+
+

Holiday-api | Webhooks

+
+
+ +
+
+ + + + + + + + + + + + + + + + + + {{range $entry := .Webhooks}} + + + + + + + + + + {{end}} + +
CountryUrlRetry countOn createOn updateOn delete
{{$entry.Country}}{{$entry.Url}}{{$entry.RetryCount}} + + +
+
+
+ + \ No newline at end of file diff --git a/webhook/model.go b/webhook/model.go new file mode 100644 index 0000000..23ba5e0 --- /dev/null +++ b/webhook/model.go @@ -0,0 +1,73 @@ +package webhook + +import ( + "encoding/json" + "github.com/google/uuid" + "time" +) + +type EventType string + +const ( + TypeCreated EventType = "created" + TypeEdited EventType = "edited" + TypeDeleted EventType = "deleted" +) + +type Event struct { + Type EventType `json:"type"` + Holiday struct { + Id uuid.UUID `json:"id"` + Name string `json:"name"` + Country string `json:"country"` + Description string `json:"description"` + Date time.Time `json:"date"` + IsStateHoliday bool `json:"is_state_holiday"` + IsReligiousHoliday bool `json:"is_religious_holiday"` + } `json:"holiday"` +} + +func (e Event) Json() string { + content, err := json.Marshal(e) + if err != nil { + panic(err) + } + return string(content) +} + +type Webhook struct { + Id uuid.UUID `db:"id"` + Created time.Time `db:"created"` + Url string `db:"url"` + Country string `db:"country"` + RetryCount int `db:"retry_count"` + OnCreated bool `db:"on_created"` + OnEdited bool `db:"on_edited"` + OnDeleted bool `db:"on_deleted"` +} + +func (w Webhook) ShouldEmit(e Event) bool { + if w.Country == e.Holiday.Country { + if e.Type == TypeCreated && w.OnCreated { + return true + } else if e.Type == TypeEdited && w.OnEdited { + return true + } else if e.Type == TypeDeleted && w.OnDeleted { + return true + } + } + return false +} + +type Job struct { + Id uuid.UUID `db:"id"` + WebhookId uuid.UUID `db:"webhook_id"` + + Created time.Time `db:"created"` + Success bool `db:"success"` + SuccessTime *time.Time `db:"success_time"` + + RetryCount int `db:"retry_count"` + + Content string `db:"content"` +} diff --git a/webhook/webhook_service.go b/webhook/webhook_service.go new file mode 100644 index 0000000..254ad7e --- /dev/null +++ b/webhook/webhook_service.go @@ -0,0 +1,141 @@ +package webhook + +import ( + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "log" + "net/http" + "strings" + "time" +) + +type WebhookService struct { + DB *sqlx.DB + Events <-chan Event + Authorization string +} + +func (w *WebhookService) Listen() { + for event := range w.Events { + webhooks, _ := w.Find() + var usedWebhooks []Webhook + for _, webhook := range webhooks { + usedWebhooks = append(usedWebhooks, webhook) + } + w.CreateJobs(usedWebhooks, event) + } +} + +func (w *WebhookService) Run() { + for { + jobs, err := w.findRunningJobs() + if err != nil { + log.Printf("failed fetching running jobs: %v", err) + } + for _, job := range jobs { + job := w.ExecuteJob(job) + if err := w.UpdateJob(job); err != nil { + log.Printf("failed updating job %v: %v", job.Id, err) + } + } + time.Sleep(2 * time.Hour) + } +} + +func (w *WebhookService) Find() ([]Webhook, error) { + var webhooks []Webhook + return webhooks, w.DB.Select(&webhooks, `SELECT * FROM "webhook"`) +} + +func (w *WebhookService) FindAllJobs() ([]Job, error) { + var jobs []Job + return jobs, w.DB.Select(&jobs, `SELECT * FROM "job"`) +} + +func (w *WebhookService) FindJobs(webhookId uuid.UUID) ([]Job, error) { + var jobs []Job + return jobs, w.DB.Select(&jobs, `SELECT * FROM "job" WHERE id = $1`, webhookId) +} + +func (w *WebhookService) findRunningJobs() ([]Job, error) { + var jobs []Job + return jobs, w.DB.Select(&jobs, `SELECT * FROM "job" WHERE retry_count > 0 ORDER BY created asc`) +} + +func (w *WebhookService) Create(webhook Webhook) (Webhook, error) { + _, err := w.DB.Exec("INSERT INTO webhook (id, created, url, country, retry_count, on_created, on_edited, on_deleted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + &webhook.Id, &webhook.Created, &webhook.Url, &webhook.Country, &webhook.RetryCount, &webhook.OnCreated, &webhook.OnEdited, &webhook.OnDeleted, + ) + return webhook, err +} + +func (w *WebhookService) Update(webhook Webhook) (Webhook, error) { + _, err := w.DB.Exec("UPDATE webhook SET url = $2, country = $3, retry_count = $4, on_created = $5, on_edited = $6, on_deleted = $7 WHERE id = $1", + &webhook.Id, &webhook.Url, &webhook.Country, &webhook.RetryCount, &webhook.OnCreated, &webhook.OnEdited, &webhook.OnDeleted, + ) + return webhook, err +} + +func (w *WebhookService) CreateJobs(webhooks []Webhook, event Event) { + json := event.Json() + + tx, _ := w.DB.Begin() + for _, webhook := range webhooks { + job := Job{ + Id: uuid.Must(uuid.NewRandom()), + WebhookId: webhook.Id, + Created: time.Now(), + Success: false, + SuccessTime: nil, + RetryCount: webhook.RetryCount, + Content: json, + } + _, err := tx.Exec("INSERT INTO job (id, webhook_id, created, success, success_time, retry_count, content) VALUES ($1, $2, $3, $4, $5, $6, $7)", + &job.Id, &job.WebhookId, &job.Created, &job.Success, &job.SuccessTime, &job.RetryCount, &job.Content, + ) + if err != nil { + tx.Rollback() + return + } + } + tx.Commit() +} + +func (w *WebhookService) ExecuteJob(job Job) Job { + webhook, err := w.FindById(job.WebhookId) + if err != nil { + panic(err) + } + log.Printf("executing job [%d:%d] %v: POST %s", webhook.RetryCount-job.RetryCount, webhook.RetryCount, job.Id, webhook.Url) + request, err := http.NewRequest("POST", webhook.Url, strings.NewReader(job.Content)) + request.Header.Add("authorization", "Api "+w.Authorization) + + response, err := http.DefaultClient.Do(request) + if err != nil || response.StatusCode != http.StatusOK { + job.RetryCount-- + } else { + job.Success = true + currentTime := time.Now() + job.SuccessTime = ¤tTime + } + return job +} + +func (w *WebhookService) FindById(webhookId uuid.UUID) (Webhook, error) { + var webhook Webhook + return webhook, w.DB.Get(&webhook, `SELECT * FROM "webhook" WHERE id = $1`, webhookId) +} + +func (w *WebhookService) UpdateJob(job Job) error { + _, err := w.DB.Exec(`UPDATE job SET success = $2, success_time = $3, retry_count = $4 WHERE id = $1`, + &job.Id, &job.Success, &job.SuccessTime, &job.RetryCount, + ) + return err +} + +func (w *WebhookService) Delete(webhookId uuid.UUID) error { + _, err := w.DB.Exec(`DELETE FROM "webhook" WHERE id = $1`, + &webhookId, + ) + return err +}