Compare commits

..

3 Commits

Author SHA1 Message Date
Borna Rajković e4f7e25d27 WIP: display jobs 2023-08-06 15:10:36 +02:00
Borna Rajković 88d9188055 Added webhook support and webhook display 2023-08-06 14:22:32 +02:00
Borna Rajković 89d246f3f6 Typos 2023-08-06 13:20:11 +02:00
18 changed files with 630 additions and 45 deletions

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.55281 1.60553C7.10941 1.32725 7.77344 1 9 1C10.2265 1 10.8906 1.32722 11.4472 1.6055L11.4631 1.61347C11.8987 1.83131 12.2359 1.99991 13 1.99993C14.2371 1.99998 14.9698 1.53871 15.2141 1.35512C15.5944 1.06932 16.0437 1.09342 16.3539 1.2369C16.6681 1.38223 17 1.72899 17 2.24148L17 13H20C21.6562 13 23 14.3415 23 15.999V19C23 19.925 22.7659 20.6852 22.3633 21.2891C21.9649 21.8867 21.4408 22.2726 20.9472 22.5194C20.4575 22.7643 19.9799 22.8817 19.6331 22.9395C19.4249 22.9742 19.2116 23.0004 19 23H5C4.07502 23 3.3148 22.7659 2.71092 22.3633C2.11331 21.9649 1.72739 21.4408 1.48057 20.9472C1.23572 20.4575 1.11827 19.9799 1.06048 19.6332C1.03119 19.4574 1.01616 19.3088 1.0084 19.2002C1.00194 19.1097 1.00003 19.0561 1 19V2.24146C1 1.72899 1.33184 1.38223 1.64606 1.2369C1.95628 1.09341 2.40561 1.06931 2.78589 1.35509C3.03019 1.53868 3.76289 1.99993 5 1.99993C5.76415 1.99993 6.10128 1.83134 6.53688 1.6135L6.55281 1.60553ZM3.00332 19L3 3.68371C3.54018 3.86577 4.20732 3.99993 5 3.99993C6.22656 3.99993 6.89059 3.67269 7.44719 3.39441L7.46312 3.38644C7.89872 3.1686 8.23585 3 9 3C9.76417 3 10.1013 3.16859 10.5369 3.38643L10.5528 3.39439C11.1094 3.67266 11.7734 3.9999 13 3.99993C13.7927 3.99996 14.4598 3.86581 15 3.68373V19C15 19.783 15.1678 20.448 15.4635 21H5C4.42498 21 4.0602 20.8591 3.82033 20.6992C3.57419 20.5351 3.39761 20.3092 3.26943 20.0528C3.13928 19.7925 3.06923 19.5201 3.03327 19.3044C3.01637 19.2029 3.00612 19.1024 3.00332 19ZM19.3044 20.9667C19.5201 20.9308 19.7925 20.8607 20.0528 20.7306C20.3092 20.6024 20.5351 20.4258 20.6992 20.1797C20.8591 19.9398 21 19.575 21 19V15.999C21 15.4474 20.5529 15 20 15H17L17 19C17 19.575 17.1409 19.9398 17.3008 20.1797C17.4649 20.4258 17.6908 20.6024 17.9472 20.7306C18.2075 20.8607 18.4799 20.9308 18.6957 20.9667C18.8012 20.9843 18.8869 20.9927 18.9423 20.9967C19.0629 21.0053 19.1857 20.9865 19.3044 20.9667Z" fill="#0F0F0F"/>
<path d="M5 8C5 7.44772 5.44772 7 6 7H12C12.5523 7 13 7.44772 13 8C13 8.55229 12.5523 9 12 9H6C5.44772 9 5 8.55229 5 8Z" fill="#0F0F0F"/>
<path d="M5 12C5 11.4477 5.44772 11 6 11H12C12.5523 11 13 11.4477 13 12C13 12.5523 12.5523 13 12 13H6C5.44772 13 5 12.5523 5 12Z" fill="#0F0F0F"/>
<path d="M5 16C5 15.4477 5.44772 15 6 15H12C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17H6C5.44772 17 5 16.5523 5 16Z" fill="#0F0F0F"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

4
assets/images/play.svg Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.46484 3.92349C4.79896 3.5739 4 4.05683 4 4.80888V19.1911C4 19.9432 4.79896 20.4261 5.46483 20.0765L19.1622 12.8854C19.8758 12.5108 19.8758 11.4892 19.1622 11.1146L5.46484 3.92349ZM2 4.80888C2 2.55271 4.3969 1.10395 6.39451 2.15269L20.0919 9.34382C22.2326 10.4677 22.2325 13.5324 20.0919 14.6562L6.3945 21.8473C4.39689 22.8961 2 21.4473 2 19.1911V4.80888Z" fill="#0F0F0F"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@ -22,29 +22,36 @@ CREATE TABLE IF NOT EXISTS "holiday"
references country(iso_name) on delete cascade on update cascade references country(iso_name) on delete cascade on update cascade
); );
INSERT INTO "country" (id, iso_name, name) CREATE TABLE IF NOT EXISTS "webhook"
VALUES (
('096ca6c4-5c04-47a4-0063-4b4cc6f6b671', 'HR', 'Croatia'), id uuid,
('096ca6c4-5c04-47a4-0063-4b4cc6f6b672', 'US', 'USA'), created timestamp NOT NULL,
('096ca6c4-5c04-47a4-0063-4b4cc6f6b673', 'FR', 'France'), url varchar(256) NOT NULL,
('096ca6c4-5c04-47a4-0063-4b4cc6f6b674', 'GB', 'Great Britain'); 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) primary key (id),
VALUES constraint fk_country_id foreign key (country)
('096ca6c4-5c04-47a4-b363-4b4cc6f6b671', 'HR', '2023-01-01', 'Nova godina', '', true, false), references country(iso_name) on delete cascade on update cascade
('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);
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
);

33
db/dev/v1_1.sql Normal file
View File

@ -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"}}');

View File

@ -5,35 +5,19 @@ CREATE TABLE IF NOT EXISTS "country"
name varchar(45) NOT NULL, name varchar(45) NOT NULL,
primary key (id) primary key (id)
); );
CREATE TABLE IF NOT EXISTS "holiday" CREATE TABLE IF NOT EXISTS "holiday"
( (
id uuid, id uuid,
country char(2) NOT NULL, country char(2) NOT NULL,
date date NOT NULL, date date NOT NULL,
name varchar(64) NOT NULL, name varchar(64) NOT NULL,
description varchar(512), description varchar(512),
is_state boolean NOT NULL, is_state boolean NOT NULL,
is_religious boolean NOT NULL, is_religious boolean NOT NULL,
primary key (id), primary key (id),
constraint fk_country_id foreign key (country) constraint fk_country_id
references co foreign key (cuntry(iso_name) on delete cascade on update cascade
primary key (id)
);
CREATE TABLE IF NOT EXISTS "holiday"
(
id uuid,
country char(2) NOT NULL,
date date NOT NULL,
name varchar(64) NOT NULL,
description varchar(512),
is_state boolean NOT NULL,
is_religious boolean NOT NULL,
primary key (id),
constraint fk_country_id foreign key (cuntry(iso_name) on delete cascade on update cascade
); );

34
db/prod/v1_1.sql Normal file
View File

@ -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
);

100
main.go
View File

@ -8,6 +8,7 @@ import (
_ "github.com/lib/pq" _ "github.com/lib/pq"
"holiday-api/holiday" "holiday-api/holiday"
"holiday-api/migration" "holiday-api/migration"
"holiday-api/webhook"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
@ -56,10 +57,15 @@ func main() {
holidayService := holiday.HolidayService{DB: client} holidayService := holiday.HolidayService{DB: client}
countryService := holiday.CountryService{DB: client} countryService := holiday.CountryService{DB: client}
yearService := holiday.YearService{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)) 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) { g.GET("/", func(c *gin.Context) {
year := time.Now().Year() year := time.Now().Year()
@ -121,18 +127,24 @@ func loadTemplates(g *gin.Engine) {
"templates/admin_dashboard.gohtml", "templates/admin_dashboard.gohtml",
"templates/countries.gohtml", "templates/countries.gohtml",
"templates/webhooks.gohtml",
"templates/jobs.gohtml",
"templates/dialogs/add-holiday.gohtml", "templates/dialogs/add-holiday.gohtml",
"templates/dialogs/edit-holiday.gohtml", "templates/dialogs/edit-holiday.gohtml",
"templates/dialogs/delete-holiday.gohtml", "templates/dialogs/delete-holiday.gohtml",
"templates/dialogs/check-is-a-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/edit-country.gohtml",
"templates/dialogs/delete-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.Use(gin.BasicAuth(loadAuth()))
adminDashboard.GET("/", func(c *gin.Context) { adminDashboard.GET("/", func(c *gin.Context) {
@ -252,10 +264,75 @@ func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.Holida
c.Redirect(http.StatusSeeOther, "/admin/countries") 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.GET("/jobs", func(c *gin.Context) {
jobs, _ := webhookService.FindAllJobs()
c.HTML(http.StatusOK, "jobs.gohtml", gin.H{"Jobs": jobs})
})
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) { adminDashboard.GET("/dialogs/add-holiday", func(c *gin.Context) {
countries, _ := countryService.Find() countries, _ := countryService.Find()
c.HTML(http.StatusOK, "add-holiday.gohtml", gin.H{"Countries": countries}) 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) { adminDashboard.GET("/dialogs/edit-holiday", func(c *gin.Context) {
id := uuid.MustParse(c.Query("id")) id := uuid.MustParse(c.Query("id"))
hol, err := service.FindById(id) hol, err := service.FindById(id)
@ -266,6 +343,16 @@ func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.Holida
countries, _ := countryService.Find() countries, _ := countryService.Find()
c.HTML(http.StatusOK, "edit-holiday.gohtml", gin.H{"Countries": countries, "Holiday": hol}) 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) { adminDashboard.GET("/dialogs/edit-country", func(c *gin.Context) {
id := uuid.MustParse(c.Query("id")) id := uuid.MustParse(c.Query("id"))
country, err := countryService.FindById(id) country, err := countryService.FindById(id)
@ -293,6 +380,15 @@ func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.Holida
} }
c.HTML(http.StatusOK, "delete-country.gohtml", gin.H{"Country": country}) 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 { func loadAuth() map[string]string {

View File

@ -97,8 +97,7 @@ func executeMigration(db *sqlx.DB, name string, script string) error {
var err error = nil var err error = nil
if _, e := tx.Exec(script); e != nil { if _, e := tx.Exec(script); e != nil {
err = e err = e
} } else if _, e := tx.Exec(createMigration, name, hash(script)); e != nil {
if _, e := tx.Exec(createMigration, name, hash(script)); e != nil {
err = e err = e
} }
if err != nil { if err != nil {

View File

@ -18,6 +18,7 @@
<section class="container"> <section class="container">
<a class="selected" href="#">Search</a> <a class="selected" href="#">Search</a>
<a href="/admin/countries">Countries</a> <a href="/admin/countries">Countries</a>
<a href="/admin/webhooks">Webhooks</a>
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button> <button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button>
</section> </section>
</nav> </nav>

View File

@ -18,6 +18,7 @@
<section class="container"> <section class="container">
<a href="/admin">Search</a> <a href="/admin">Search</a>
<a class="selected" href="#">Countries</a> <a class="selected" href="#">Countries</a>
<a href="/admin/webhooks">Webhooks</a>
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button> <button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button>
</section> </section>
</nav> </nav>

View File

@ -0,0 +1,35 @@
<dialog class="card" id="create-card">
<h3 class="card-title">Create webhook</h3>
<form method="post" action="/admin/webhooks">
<section>
<label for="url">Url:</label>
<input required id="url" name="url" type="url">
</section>
<section>
<label for="country">Country:</label>
<select id="country" required name="country">
{{range $entry := .Countries}}
<option value="{{$entry.IsoName}}">{{$entry.Name}}</option>
{{end}}
</select>
</section>
<section>
<label for="retry_count">Retry count:</label>
<input required id="retry_count" name="retry_count" type="number" min="0" max="10">
</section>
<section>
<label class="checkbox"><span>On created</span><input id="on_created" value="true" name="on_created" type="checkbox"></label>
</section>
<section>
<label class="checkbox"><span>On edited</span><input id="on_edited" value="true" name="on_edited" type="checkbox"></label>
</section>
<section>
<label class="checkbox"><span>On deleted</span><input id="on_deleted" value="true" name="on_deleted" type="checkbox"></label>
</section>
<section class="actions">
<button type="submit">Create</button>
<button type="button" onclick="closeDialog('#create-card')">Cancel</button>
</section>
</form>
</dialog>

View File

@ -0,0 +1,11 @@
<dialog class="card" id="delete-card">
<h3 class="card-title">Delete webhook</h3>
<p>Are you sure you want to delete webhook to <br><b>{{.Webhook.Url}}?</b><br>All jobs for given webhook will be deleted!</p>
<form method="post" action="/admin/webhooks/{{.Webhook.Id}}/delete">
<section class="actions">
<button type="submit">Delete</button>
<button type="button" onclick="closeDialog('#delete-card')">Cancel</button>
</section>
</form>
</dialog>

View File

@ -0,0 +1,36 @@
<dialog class="card" id="update-card">
<h3 class="card-title">Edit webhook</h3>
<form method="post" action="/admin/webhooks">
<input type="hidden" id="id" name="id" value="{{.Webhook.Id}}">
<section>
<label for="url">Url:</label>
<input required id="url" name="url" type="url" value="{{.Webhook.Url}}">
</section>
<section>
<label for="country">Country:</label>
<select id="country" required name="country">
{{range $entry := .Countries}}
<option {{if eq $.Webhook.Country $entry.IsoName}}selected{{end}} value="{{$entry.IsoName}}">{{$entry.Name}}</option>
{{end}}
</select>
</section>
<section>
<label for="retry_count">Retry count:</label>
<input required id="retry_count" name="retry_count" type="number" min="0" max="10" value="{{.Webhook.RetryCount}}">
</section>
<section>
<label class="checkbox"><span>On created</span><input id="on_created" value="true" name="on_created" type="checkbox" {{if .Webhook.OnCreated}}checked{{end}}></label>
</section>
<section>
<label class="checkbox"><span>On edited</span><input id="on_edited" value="true" name="on_edited" type="checkbox" {{if .Webhook.OnEdited}}checked{{end}}></label>
</section>
<section>
<label class="checkbox"><span>On deleted</span><input id="on_deleted" value="true" name="on_deleted" type="checkbox" {{if .Webhook.OnDeleted}}checked{{end}}></label>
</section>
<section class="actions">
<button type="submit">Update</button>
<button type="button" onclick="closeDialog('#update-card')">Cancel</button>
</section>
</form>
</dialog>

59
templates/jobs.gohtml Normal file
View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Holiday-api | Admin dashboard</title>
<link rel="stylesheet" href="/assets/style.css">
<script src="/assets/global.js"></script>
</head>
<body>
<div id="dialog-container"></div>
<header>
<section class="container">
<h1><a href="/">Holiday-api | Jobs</a></h1>
</section>
</header>
<nav>
<section class="container">
<a href="/admin">Search</a>
<a href="/admin/countries">Countries</a>
<a href="/admin/webhooks">Webhooks</a>
<a href="#" class="selected">Jobs</a>
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button>
</section>
</nav>
<main>
<section style="margin: 1em; flex-grow: 1">
<table style="width: 100%">
<thead>
<tr>
<th>Webhook</th>
<th>Created</th>
<th>Success</th>
<th>Retries remaining</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $entry := .Jobs}}
<tr>
<td>{{$entry.WebhookId}}</td>
<td>{{$entry.Created.Format "2006-01-02 15:04:05"}}</td>
<td>
<img class="icon" src="{{if $entry.Success}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}">
</td>
<td>{{$entry.RetryCount}}</td>
<td>
<button type="button" data-type="dialog" data-trigger="#update-card" data-url="/admin/dialogs/rerun-job?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/play.svg"></button>
<button type="button" data-type="dialog" data-trigger="#delete-card" data-url="/admin/dialogs/delete-job?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/trash-delete.svg"></button>
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
</main>
</body>
</html>

64
templates/webhooks.gohtml Normal file
View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Holiday-api | Admin dashboard</title>
<link rel="stylesheet" href="/assets/style.css">
<script src="/assets/global.js"></script>
</head>
<body>
<div id="dialog-container"></div>
<header>
<section class="container">
<h1><a href="/">Holiday-api | Webhooks</a></h1>
</section>
</header>
<nav>
<section class="container">
<a href="/admin">Search</a>
<a href="/admin/countries">Countries</a>
<a class="selected" href="#">Webhooks</a>
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button>
</section>
</nav>
<main>
<section style="margin: 1em; flex-grow: 1">
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-webhook" style="margin-bottom: 1em; float: right">Create webhook</button>
<table style="width: 100%">
<thead>
<tr>
<th>Country</th>
<th>Url</th>
<th>Retry count</th>
<th>On create</th>
<th>On update</th>
<th>On delete</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $entry := .Webhooks}}
<tr>
<td>{{$entry.Country}}</td>
<td>{{$entry.Url}}</td>
<td>{{$entry.RetryCount}}</td>
<td><img class="icon" src="{{if $entry.OnCreated}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
<td><img class="icon" src="{{if $entry.OnEdited}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
<td><img class="icon" src="{{if $entry.OnDeleted}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
<td>
<a style="display: inline-block; font-size: 0.8em" href="/admin/jobs?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/invoice.svg"></a>
<button type="button" data-type="dialog" data-trigger="#update-card" data-url="/admin/dialogs/edit-webhook?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/edit.svg"></button>
<button type="button" data-type="dialog" data-trigger="#delete-card" data-url="/admin/dialogs/delete-webhook?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/trash-delete.svg"></button>
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
</main>
</body>
</html>

73
webhook/model.go Normal file
View File

@ -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"`
}

141
webhook/webhook_service.go Normal file
View File

@ -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 = &currentTime
}
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
}