diff --git a/README.md b/README.md index 280b8fa..4c6a1f2 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ - [ ] Start with just a list of events for a given store (use a static test store) - [ ] ... +## Nice to haves + +- [ ] Drop in a good logger + Looking to follow the [CQRS](https://martinfowler.com/bliki/CQRS.html) pattern. diff --git a/go.mod b/go.mod index f436bec..c7308e3 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,12 @@ module ruben/inventory2 go 1.24.2 +replace github.com/angelbeltran/templater v0.1.0 => ../../github.com/angelbeltran/templater + require github.com/jackc/pgx/v5 v5.7.6 +require github.com/angelbeltran/templater v0.1.0 + require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/internal/domains/raw_events/events.go b/internal/domains/raw_events/events.go index d40e472..840f382 100644 --- a/internal/domains/raw_events/events.go +++ b/internal/domains/raw_events/events.go @@ -16,6 +16,12 @@ type ( db *pgxpool.Pool } + // StoreWithContext allows currying all Store methods by context.Context + StoreWithContext struct { + ctx context.Context + db *Store + } + Event struct { Platform string StoreID string @@ -51,6 +57,13 @@ func newPool(ctx context.Context) (*pgxpool.Pool, error) { return pool, nil } +func (db *Store) WithContext(ctx context.Context) *StoreWithContext { + return &StoreWithContext{ + ctx: ctx, + db: db, + } +} + func (db *Store) Save(ctx context.Context, e *Event) error { _, err := db.db.Exec( ctx, @@ -87,3 +100,55 @@ func (db *Store) Save(ctx context.Context, e *Event) error { return nil } + +func (db *StoreWithContext) Save(e *Event) error { + return db.db.Save(db.ctx, e) +} + +func (db *Store) LoadEventsForStore( + ctx context.Context, + platform string, + storeID string, +) ([]Event, error) { + rows, err := db.db.Query( + ctx, + ` + SELECT + platform as Platform, + store_id as StoreID, + event_timestamp as EventTimestamp, + event_id as EventID, + raw_payload as Payload + FROM + raw_store_events + WHERE + platform = @platform + AND store_id = @store_id + ORDER BY + event_timestamp DESC + LIMIT + 100 + `, + pgx.NamedArgs{ + "platform": platform, + "store_id": storeID, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to perform query: %w", err) + } + + evts, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[Event]) + if err != nil { + return nil, fmt.Errorf("failed to scan events: %w", err) + } + + return evts, nil +} + +func (db *StoreWithContext) LoadEventsForStore( + platform string, + storeID string, +) ([]Event, error) { + return db.db.LoadEventsForStore(db.ctx, platform, storeID) +} diff --git a/internal/site/scripts/_hyperscript.min.js.gz b/internal/site/scripts/_hyperscript.min.js.gz new file mode 100644 index 0000000..0ec5339 Binary files /dev/null and b/internal/site/scripts/_hyperscript.min.js.gz differ diff --git a/internal/site/scripts/htmx.min.js.gz b/internal/site/scripts/htmx.min.js.gz new file mode 100644 index 0000000..396e9d4 Binary files /dev/null and b/internal/site/scripts/htmx.min.js.gz differ diff --git a/internal/site/site.go b/internal/site/site.go index 4c8b836..82c9d7d 100644 --- a/internal/site/site.go +++ b/internal/site/site.go @@ -1,26 +1,120 @@ package site import ( + "encoding/json" + "fmt" + "html/template" "net/http" + "net/url" + "path" + "strings" + + "github.com/angelbeltran/templater" + + "ruben/inventory2/internal/domains/raw_events" ) -func NewSiteHandler() http.Handler { +func NewSiteHandler(dir string, db *raw_events.Store) http.Handler { mux := http.NewServeMux() + // non-html routes + + scfs := http.FileServer(http.Dir(dir + "/scripts")) + mux.Handle("/scripts/", http.StripPrefix("/scripts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/javascript") + if path.Ext(r.URL.Path) == ".gz" { + w.Header().Set("Content-Encoding", "gzip") + } + scfs.ServeHTTP(w, r) + }))) + mux.Handle("/styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(dir+"/styles")))) + + // html page routes + + tmplr := templater.NewTemplater( + dir+"/templates", + func() template.FuncMap { + return template.FuncMap{ + "buildSitePath": func(parts ...any) string { + strParts := make([]string, len(parts)) + for i, p := range parts { + strParts[i] = fmt.Sprint(p) + } + + // TODO: make "/site" dynamic somehow + return path.Join(append([]string{"/site"}, strParts...)...) + }, + + "prettyPrintJSON": func(j json.RawMessage) string { + b, err := json.MarshalIndent(j, " ", "") + if err != nil { + return string(j) + } + return string(b) + }, + + "addInt": func(a, b int) int { + return a + b + }, + "subInt": func(a, b int) int { + return a - b + }, + "multInt": func(a, b int) int { + return a * b + }, + } + }, + ) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(` - - - - TEST - - - TEST - - -`)) + b, err := tmplr.ExecutePage( + getPageTemplateNameForURL(r.URL), + "Request", + r, + // add services here + "RawEvents", + db.WithContext(r.Context()), + "URLCalc", + newURLCalculator(r.URL), + ) + if err != nil { + // TODO: handle 'not found' as a 404? + fmt.Println("[ERROR]: failed to load or parse layout template:", err) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + w.Write(b) }) return mux } + +func getPageTemplateNameForURL(u *url.URL) string { + filepath := strings.TrimPrefix(strings.TrimSuffix(u.Path, ".html"), "/") + if filepath == "" { + // "/" maps to "/home" + filepath = "home" + } + + return filepath +} + +type URLCalculator struct { + url *url.URL +} + +func newURLCalculator(u *url.URL) URLCalculator { + cpy := *u + return URLCalculator{ + url: &cpy, + } +} + +func (c URLCalculator) SetQueryParam(k string, v any) string { + u := *c.url + q := u.Query() + q.Set(k, fmt.Sprint(v)) + u.RawQuery = q.Encode() + + return u.String() +} diff --git a/internal/site/styles/index.css b/internal/site/styles/index.css new file mode 100644 index 0000000..6d29716 --- /dev/null +++ b/internal/site/styles/index.css @@ -0,0 +1,10 @@ +ul, ol { + /* remove default agent spacing */ + margin: 0; + padding: 0; + list-style: none; + + & > li { + display: inline-block; + } +} diff --git a/internal/site/templates/component_bodies/nav_bar.html.tmpl b/internal/site/templates/component_bodies/nav_bar.html.tmpl new file mode 100644 index 0000000..fb83940 --- /dev/null +++ b/internal/site/templates/component_bodies/nav_bar.html.tmpl @@ -0,0 +1,16 @@ + + diff --git a/internal/site/templates/layout.html.tmpl b/internal/site/templates/layout.html.tmpl new file mode 100644 index 0000000..662b5c9 --- /dev/null +++ b/internal/site/templates/layout.html.tmpl @@ -0,0 +1,19 @@ + + + + WIP + + + + + + + {{/* This is where the page_head template will be inserted into the page */}} + {{- block "head" . }}{{ end }} + + + + {{/* This is where the page_body template will be inserted into the page */}} + {{- block "body" . }}{{ end }} + + diff --git a/internal/site/templates/page_bodies/home.html.tmpl b/internal/site/templates/page_bodies/home.html.tmpl new file mode 100644 index 0000000..7cce70e --- /dev/null +++ b/internal/site/templates/page_bodies/home.html.tmpl @@ -0,0 +1,3 @@ +{{ componentBody "nav_bar" }} + +

Home

diff --git a/internal/site/templates/page_bodies/reports.html.tmpl b/internal/site/templates/page_bodies/reports.html.tmpl new file mode 100644 index 0000000..570787e --- /dev/null +++ b/internal/site/templates/page_bodies/reports.html.tmpl @@ -0,0 +1,89 @@ +{{ componentBody "nav_bar" }} + +{{ $storeID := .Request.URL.Query.Get "store-id" }} + +

Reports

+ +
+ + + {{- if $storeID }} +
+
+ Date +
+
+ Time +
+
+ ID +
+
+ Payload +
+ {{ $events := .RawEvents.LoadEventsForStore "etsy" $storeID }} + {{ range $e := $events }} +
+ {{ printf "%d/%02d/%02d" $e.EventTimestamp.Year $e.EventTimestamp.Month $e.EventTimestamp.Day }} +
+
+ {{ $suffix := "am" }} + {{ $ts := $e.EventTimestamp }} + {{- $hr := $ts.Hour }} + {{- if eq $hr 0 }} + {{- $hr = 12 }} + {{- else if eq $hr 12 }} + {{- $suffix = "pm" }} + {{- else if gt $hr 12 }} + {{- $hr = subInt $hr 12 }} + {{- $suffix = "pm" }} + {{- end }} + + {{- $hrColorHex := multInt $hr 10 | subInt (subInt 256 128) | printf "%02x" }} + {{- $minColorHex := multInt $ts.Minute 2 | subInt (subInt 256 128) | printf "%02x" }} + {{- $secColorHex := multInt $ts.Second 2 | subInt (subInt 256 128) | printf "%02x" }} + {{ printf "%02d" $hr }}:{{ printf "%02d" $ts.Minute }}:{{ printf "%02d" $ts.Second }}{{ printf " %s" $suffix }} +
+
+ {{ $e.EventID }} +
+
+ {{ prettyPrintJSON $e.Payload }} +
+ {{ end }} +
+ {{- end }} +
diff --git a/internal/webhooks/etsy/webhooks.go b/internal/webhooks/etsy/webhooks.go index 5d60a75..38305c9 100644 --- a/internal/webhooks/etsy/webhooks.go +++ b/internal/webhooks/etsy/webhooks.go @@ -21,9 +21,17 @@ func NewWebhookHandler(db *raw_events.Store) http.Handler { ts := time.Now().UTC() + storeID := "test-store-id" + var payloadObject struct { + StoreID string + } + if err := json.Unmarshal(body, &payloadObject); err == nil && payloadObject.StoreID != "" { + storeID = payloadObject.StoreID + } + err := db.Save(r.Context(), &raw_events.Event{ Platform: "etsy", - StoreID: "test-store-1", + StoreID: storeID, EventID: fmt.Sprint(ts.Unix()), EventTimestamp: ts, Payload: body, diff --git a/main.go b/main.go index 3642c82..8bdde4a 100644 --- a/main.go +++ b/main.go @@ -73,7 +73,7 @@ func runApp(ctx context.Context) error { func runServer(ctx context.Context, db *raw_events.Store) <-chan error { srv := &http.Server{ - Addr: ":9000", // local + Addr: ":8082", // local Handler: buildHTTPHandler(db), } @@ -86,7 +86,7 @@ func runServer(ctx context.Context, db *raw_events.Store) <-chan error { defer cancel() defer close(alreadyShutdownCh) - fmt.Println("server running on 9000...") + fmt.Println("server running on 8082...") if err := srv.ListenAndServe(); err != nil { if !errors.Is(err, http.ErrServerClosed) { runningErrCh <- fmt.Errorf("server experienced error: %w", err) @@ -129,7 +129,8 @@ func buildHTTPHandler(db *raw_events.Store) http.Handler { mux := http.NewServeMux() mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(db))) - mux.Handle("/site/", http.StripPrefix("/site", site.NewSiteHandler())) + mux.Handle("/site/", http.StripPrefix("/site", site.NewSiteHandler("./internal/site", db))) + mux.Handle("/", http.RedirectHandler("/site", http.StatusPermanentRedirect)) return mux }