From 645d6b617a95243b033c24729ac682efba8e67d9 Mon Sep 17 00:00:00 2001 From: collin Date: Sun, 22 Jun 2025 21:59:09 +0200 Subject: [PATCH] Adding basic backend to handle RSVPs --- .vscode/settings.json | 3 + server/controller.go | 57 ++++++++++++++ server/database.go | 26 +++++++ server/go.mod | 10 +++ server/go.sum | 9 +++ server/main.go | 17 +++++ server/ntfy.go | 165 +++++++++++++++++++++++++++++++++++++++++ server/ntfy_test.go | 75 +++++++++++++++++++ server/rsvp.go | 117 +++++++++++++++++++++++++++++ server/rsvp_test.go | 169 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 648 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 server/controller.go create mode 100644 server/database.go create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/main.go create mode 100644 server/ntfy.go create mode 100644 server/ntfy_test.go create mode 100644 server/rsvp.go create mode 100644 server/rsvp_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..100300e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["authd", "gotfy", "ntfy", "Rsvps"] +} diff --git a/server/controller.go b/server/controller.go new file mode 100644 index 0000000..af277da --- /dev/null +++ b/server/controller.go @@ -0,0 +1,57 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type Handler struct { + db *sql.DB + ntfy *Ntfy +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + fmt.Printf("%s - [%s] (%s) %s\n", time.Now().Format(time.RFC3339), r.RemoteAddr, r.Method, r.URL) + + switch true { + case r.Method == "GET" && r.URL.Path == "/": + rsvps, err := GetRsvps(h.db) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "%s", err.Error()) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "%#v", rsvps) + case r.Method == "POST" && r.URL.Path == "/": + var rsvp Rsvp + + err := json.NewDecoder(r.Body).Decode(&rsvp) + + if err != nil { + fmt.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + _, err = rsvp.CreateRsvp(h.db) + fmt.Printf("%#v\n", rsvp) + + if err != nil { + fmt.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if h.ntfy != nil { + SendRsvpNotification(h.ntfy, &rsvp) + } + w.WriteHeader(http.StatusAccepted) + default: + w.WriteHeader(http.StatusNotFound) + } +} diff --git a/server/database.go b/server/database.go new file mode 100644 index 0000000..0f1fc81 --- /dev/null +++ b/server/database.go @@ -0,0 +1,26 @@ +package main + +import ( + "database/sql" + "fmt" + "log" +) + +func SetupDatabase() *sql.DB { + db, err := sql.Open("sqlite3", "./db.sqlite") + + if err != nil { + log.Fatal("failed to open database") + } + + var version string + err = db.QueryRow("SELECT SQLITE_VERSION()").Scan(&version) + + if err != nil { + log.Fatal("failed to query database") + } + + fmt.Println(version) + + return db +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..d1c65f3 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,10 @@ +module collinenlucy.nl + +go 1.24.4 + +require ( + github.com/AnthonyHewins/gotfy v0.0.10 // indirect + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/mattn/go-sqlite3 v1.14.28 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..e517b98 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,9 @@ +github.com/AnthonyHewins/gotfy v0.0.10 h1:23ZjRVG7wuGuqn7CQq/bOrkXa3gg2XyYrrD3RYEmvE8= +github.com/AnthonyHewins/gotfy v0.0.10/go.mod h1:q2orErDDpl9/gZ5L4oJhejb7TaP/eBdtkzjWDruNRlg= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..3c2be00 --- /dev/null +++ b/server/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "net/http" + + _ "github.com/mattn/go-sqlite3" +) + +func main() { + ntfy := SetupNtfyClient() + + db := SetupDatabase() + SetupRsvpsTable(db) + + hnd := &Handler{db, ntfy} + http.ListenAndServe(":8000", hnd) +} diff --git a/server/ntfy.go b/server/ntfy.go new file mode 100644 index 0000000..4e7c23d --- /dev/null +++ b/server/ntfy.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "os" + "strings" + + "github.com/AnthonyHewins/gotfy" +) + +type authdTransport struct { + underlyingTransport http.RoundTripper +} + +func (t *authdTransport) RoundTrip(req *http.Request) (*http.Response, error) { + var ntfyToken string + env := os.Environ() + for _, keyVal := range env { + keyValArr := strings.Split(keyVal, "=") + if keyValArr[0] == "NTFY_TOKEN" { + ntfyToken = keyValArr[1] + } + } + + req.Header.Add("Authorization", "Bearer "+ntfyToken) + return t.underlyingTransport.RoundTrip(req) +} + +type Ntfy struct { + client *gotfy.Publisher +} + +func SetupNtfyClient() *Ntfy { + host := "" + env := os.Environ() + for _, keyVal := range env { + keyValArr := strings.Split(keyVal, "=") + if keyValArr[0] == "NTFY_HOST" { + host = keyValArr[1] + } + } + + if host == "" { + return nil + } + + server, err := url.Parse(host) + + if err != nil { + log.Fatal(err) + } + + authdHttpClient := &http.Client{Transport: &authdTransport{underlyingTransport: http.DefaultTransport}} + + tp, err := gotfy.NewPublisher(server, authdHttpClient) + + if err != nil { + log.Fatal(err) + } + + if err != nil { + log.Fatal(err) + } + + return &Ntfy{client: tp} +} + +func buildTitle(rsvp *Rsvp) string { + builder := new(strings.Builder) + if rsvp.Attending { + peoplePerson := "people" + if rsvp.PartySize == 1 { + peoplePerson = "person" + } + fmt.Fprintf(builder, "%d %s confirmed!", rsvp.PartySize, peoplePerson) + } else { + fmt.Fprintf(builder, "Someone can't make it") + } + return builder.String() +} + +func buildMessage(rsvp *Rsvp) string { + builder := new(strings.Builder) + if rsvp.Attending { + builder.WriteString("Here's who is coming 👇") + for _, mem := range rsvp.PartyMembers { + age := "adult" + if mem.Child { + age = "👶" + } else { + age = "🧓" + } + dp := "n/a" + if len(mem.DietaryPreferences) > 0 { + dp = mem.DietaryPreferences + } + fmt.Fprintf(builder, "\n- %s %s, %s", age, mem.Name, dp) + } + } else { + for _, mem := range rsvp.PartyMembers { + fmt.Fprintf(builder, "%s\n", mem.Name) + } + builder.WriteString("can't make it") + } + return builder.String() +} + +func numberToEmoji(num int64) string { + switch num { + case 0: + return "zero" + case 1: + return "one" + case 2: + return "two" + case 3: + return "three" + case 4: + return "four" + case 5: + return "five" + case 6: + return "six" + case 7: + return "seven" + case 8: + return "eight" + case 9: + return "nine" + default: + return "question" + } +} + +func buildTags(rsvp *Rsvp) []string { + if rsvp.Attending { + return []string{"white_check_mark", numberToEmoji(rsvp.PartySize)} + } else { + return []string{"x"} + } +} + +func BuildNtfyMessage(topic string, rsvp *Rsvp) *gotfy.Message { + return &gotfy.Message{ + Topic: topic, + Message: buildMessage(rsvp), + Title: buildTitle(rsvp), + Tags: buildTags(rsvp), + Priority: gotfy.Default, + } +} + +func (n *Ntfy) PublishNewRsvpNotification(rsvp *Rsvp) (string, error) { + resp, err := n.client.SendMessage(context.Background(), BuildNtfyMessage("collinenlucy_nl", rsvp)) + + if err != nil { + return "", err + } + + return resp.ID, nil +} diff --git a/server/ntfy_test.go b/server/ntfy_test.go new file mode 100644 index 0000000..e4a7798 --- /dev/null +++ b/server/ntfy_test.go @@ -0,0 +1,75 @@ +package main + +import "testing" + +func TestBuildNtfyMessage(t *testing.T) { + rsvp := &Rsvp{ + Id: 1, + Attending: false, + PartySize: 1, + PartyMembers: []Member{ + {Name: "test", Child: false, DietaryPreferences: ""}, + }, + } + msg := BuildNtfyMessage("test", rsvp) + + if msg.Topic != "test" { + t.Fatal("message topic is incorrect") + } + if msg.Title != "Someone can't make it" { + t.Fatal("message title is incorrect") + } + if msg.Message != "test\ncan't make it" { + t.Fatal("message message is incorrect") + } + if len(msg.Tags) != 1 || msg.Tags[0] != "x" { + t.Fatal("message tags are incorrect") + } + + rsvp = &Rsvp{ + Id: 1, + Attending: true, + PartySize: 1, + PartyMembers: []Member{ + {Name: "test", Child: false, DietaryPreferences: ""}, + }, + } + msg = BuildNtfyMessage("test", rsvp) + + if msg.Topic != "test" { + t.Fatal("message topic is incorrect") + } + if msg.Title != "1 person confirmed!" { + t.Fatal("message title is incorrect") + } + if msg.Message != "Here's who is coming 👇\n- 🧓 test, n/a" { + t.Fatalf("message message is incorrect") + } + if len(msg.Tags) != 2 || msg.Tags[0] != "white_check_mark" || msg.Tags[1] != "one" { + t.Fatal("message tags are incorrect") + } + + rsvp = &Rsvp{ + Id: 1, + Attending: true, + PartySize: 2, + PartyMembers: []Member{ + {Name: "test1", Child: false, DietaryPreferences: ""}, + {Name: "test2", Child: true, DietaryPreferences: "no tobacco"}, + }, + } + msg = BuildNtfyMessage("test", rsvp) + + if msg.Topic != "test" { + t.Fatal("message topic is incorrect") + } + if msg.Title != "2 people confirmed!" { + t.Fatal("message title is incorrect") + } + if msg.Message != "Here's who is coming 👇\n- 🧓 test1, n/a\n- 👶 test2, no tobacco" { + t.Fatalf("message message is incorrect") + } + if len(msg.Tags) != 2 || msg.Tags[0] != "white_check_mark" || msg.Tags[1] != "two" { + t.Fatal("message tags are incorrect") + } +} diff --git a/server/rsvp.go b/server/rsvp.go new file mode 100644 index 0000000..2cfdb66 --- /dev/null +++ b/server/rsvp.go @@ -0,0 +1,117 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" +) + +type Member struct { + Name string `json:"name"` + DietaryPreferences string `json:"dietaryPreferences"` + Child bool `json:"child"` +} + +type Rsvp struct { + Id int64 `json:"id"` + Attending bool `json:"attending"` + PartySize int64 `json:"partySize"` + PartyMembers []Member `json:"partyMembers"` +} + +func SetupRsvpsTable(db *sql.DB) { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS rsvps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attending BOOLEAN NOT NULL, + partySize INTEGER NOT NULL, + partyMembers BLOB NOT NULL + );`) + + if err != nil { + log.Fatalf("failed to create rsvp table: %s", err.Error()) + } +} + +func GetRsvps(db *sql.DB) ([]Rsvp, error) { + rows, err := db.Query("SELECT * FROM rsvps ORDER BY id DESC;") + if err != nil { + return nil, err + } + + defer rows.Close() + + rsvps := make([]Rsvp, 0) + + for rows.Next() { + var id int64 + var attending bool + var partySize int64 + var rawPartyMembers []byte + + err = rows.Scan(&id, &attending, &partySize, &rawPartyMembers) + + if err != nil { + return nil, err + } + + var members []Member + err := json.Unmarshal(rawPartyMembers, &members) + + if err != nil { + return nil, err + } + + rsvps = append(rsvps, Rsvp{ + Id: id, + Attending: attending, + PartySize: partySize, + PartyMembers: members, + }) + } + + return rsvps, nil +} + +func (rsvp *Rsvp) CreateRsvp(db *sql.DB) (int64, error) { + partyMembersBytes, err := json.Marshal(rsvp.PartyMembers) + if err != nil { + return -1, err + } + fmt.Printf("%s\n", partyMembersBytes) + + result, err := db.Exec( + "INSERT INTO rsvps (attending, partySize, partyMembers) VALUES (?, ?, ?);", + rsvp.Attending, + rsvp.PartySize, + partyMembersBytes, + ) + + if err != nil { + return -1, err + } + + id, err := result.LastInsertId() + + if err != nil { + return -1, err + } + + rsvp.Id = id + + return id, nil +} + +type RsvpPublisher interface { + PublishNewRsvpNotification(rsvp *Rsvp) (string, error) +} + +func SendRsvpNotification(publisher RsvpPublisher, rsvp *Rsvp) { + resp, err := publisher.PublishNewRsvpNotification(rsvp) + + if err != nil { + fmt.Printf("failed publishing notification: %s\n", err.Error()) + } + + fmt.Printf("published notification: %s\n", resp) +} diff --git a/server/rsvp_test.go b/server/rsvp_test.go new file mode 100644 index 0000000..9b7c56a --- /dev/null +++ b/server/rsvp_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "bytes" + "encoding/json" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" +) + +type mockPublisher struct { + timesCalled int +} + +func (m *mockPublisher) PublishNewRsvpNotification(rsvp *Rsvp) (string, error) { + m.timesCalled++ + return "test", nil +} + +func TestSendRsvpNotification(t *testing.T) { + rsvp := &Rsvp{ + Id: 1, + Attending: true, + PartySize: 1, + PartyMembers: []Member{ + {Name: "test", Child: false, DietaryPreferences: ""}, + }, + } + mp := &mockPublisher{ + timesCalled: 0, + } + SendRsvpNotification(mp, rsvp) + + if mp.timesCalled != 1 { + t.Fatalf("failed to call PublishNewRsvpNotification") + } +} + +func TestUnmarshal(t *testing.T) { + var blob = []byte(`{"id":1,"attending":true,"partySize":2,"partyMembers":[{"name":"test1","child":false,"dietaryPreferences":"none"},{"name":"test2","child":true,"dietaryPreferences":"some"}]}`) + var rsvp Rsvp + err := json.Unmarshal(blob, &rsvp) + if err != nil { + t.Fatal(err) + } + + if rsvp.Id != 1 { + t.Fatal("failed to unmarshal id") + } + if !rsvp.Attending { + t.Fatal("failed to unmarshal attending") + } + if rsvp.PartySize != 2 { + t.Fatal("failed to unmarshal partySize") + } + if len(rsvp.PartyMembers) != 2 || rsvp.PartyMembers[0].Name != "test1" || rsvp.PartyMembers[0].Child || rsvp.PartyMembers[0].DietaryPreferences != "none" || rsvp.PartyMembers[1].Name != "test2" || !rsvp.PartyMembers[1].Child || rsvp.PartyMembers[1].DietaryPreferences != "some" { + t.Fatal("failed to unmarshal partyMembers") + } +} + +func TestMarshal(t *testing.T) { + rsvp := Rsvp{ + Id: 1, + Attending: true, + PartySize: 2, + PartyMembers: []Member{ + {Name: "test1", Child: false, DietaryPreferences: "none"}, + {Name: "test2", Child: true, DietaryPreferences: "some"}, + }, + } + + marshalled, err := json.Marshal(rsvp) + if err != nil { + t.Fatal("failed to marshal") + } + + var expected = []byte(`{"id":1,"attending":true,"partySize":2,"partyMembers":[{"name":"test1","dietaryPreferences":"none","child":false},{"name":"test2","dietaryPreferences":"some","child":true}]}`) + if !bytes.Equal(marshalled, expected) { + t.Fatalf("failed to produce matching bytes,\ngot:\t\t\t%s\nexpected:\t%s\n", marshalled, expected) + } +} + +func TestGetRsvps(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal("failed to create mock database") + } + + columns := []string{"id", "attending", "party_size", "party_members"} + mock.ExpectQuery( + "SELECT \\* FROM rsvps ORDER BY id DESC;", + ). + WillReturnRows( + sqlmock. + NewRows(columns). + AddRow( + 1, + true, + 2, + []byte(`[{"name":"test1","child":false,"dietaryPreferences":"none"},{"name":"test2","child":true,"dietaryPreferences":"some"}]`), + ), + ) + + rsvps, err := GetRsvps(db) + if err != nil { + t.Fatal(err) + } + + if len(rsvps) != 1 { + t.Fatal("didn't receive 1 rsvp") + } + + rsvp := rsvps[0] + + if rsvp.Id != 1 { + t.Fatal("incorrect id") + } + + if !rsvp.Attending { + t.Fatal("incorrect attending") + } + + if rsvp.PartySize != 2 { + t.Fatal("incorrect party_size") + } + + if len(rsvp.PartyMembers) != 2 { + t.Fatal("incorrect party_members length ") + } + + if rsvp.PartyMembers[0].Name != "test1" || rsvp.PartyMembers[0].Child || rsvp.PartyMembers[0].DietaryPreferences != "none" { + t.Fatal("incorrect party_members[0]") + } + + if rsvp.PartyMembers[1].Name != "test2" || !rsvp.PartyMembers[1].Child || rsvp.PartyMembers[1].DietaryPreferences != "some" { + t.Fatal("incorrect party_members[1]") + } +} + +func TestCreateRsvp(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal("failed to create mock database") + } + + mock.ExpectExec( + "INSERT INTO rsvps \\(attending, partySize, partyMembers\\) VALUES \\(\\?, \\?, \\?\\);", + ). + WithArgs(true, 2, []byte(`[{"name":"test","dietaryPreferences":"none","child":false}]`)). + WillReturnResult(sqlmock.NewResult(1, 1)) + + rsvp := Rsvp{ + Id: -1, + Attending: true, + PartySize: 2, + PartyMembers: []Member{ + {Name: "test", Child: false, DietaryPreferences: "none"}, + }, + } + id, err := rsvp.CreateRsvp(db) + + if err != nil { + t.Fatal(err) + } + + if id != 1 || rsvp.Id != 1 { + t.Fatal("received wrong id") + } +}