prototype report page

This commit is contained in:
2025-12-28 14:38:06 -07:00
parent 49bbe281df
commit 051cf1da5d
13 changed files with 330 additions and 17 deletions
+4
View File
@@ -9,6 +9,10 @@
- [ ] Start with just a list of events for a given store (use a static test store) - [ ] 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. Looking to follow the [CQRS](https://martinfowler.com/bliki/CQRS.html) pattern.
+4
View File
@@ -2,8 +2,12 @@ module ruben/inventory2
go 1.24.2 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/jackc/pgx/v5 v5.7.6
require github.com/angelbeltran/templater v0.1.0
require ( require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+65
View File
@@ -16,6 +16,12 @@ type (
db *pgxpool.Pool db *pgxpool.Pool
} }
// StoreWithContext allows currying all Store methods by context.Context
StoreWithContext struct {
ctx context.Context
db *Store
}
Event struct { Event struct {
Platform string Platform string
StoreID string StoreID string
@@ -51,6 +57,13 @@ func newPool(ctx context.Context) (*pgxpool.Pool, error) {
return pool, nil 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 { func (db *Store) Save(ctx context.Context, e *Event) error {
_, err := db.db.Exec( _, err := db.db.Exec(
ctx, ctx,
@@ -87,3 +100,55 @@ func (db *Store) Save(ctx context.Context, e *Event) error {
return nil 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)
}
Binary file not shown.
Binary file not shown.
+107 -13
View File
@@ -1,26 +1,120 @@
package site package site
import ( import (
"encoding/json"
"fmt"
"html/template"
"net/http" "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() 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) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") b, err := tmplr.ExecutePage(
w.Write([]byte(` getPageTemplateNameForURL(r.URL),
<DOCTYPE html> "Request",
<html> r,
<head> // add services here
<title>TEST</title> "RawEvents",
</head> db.WithContext(r.Context()),
<body> "URLCalc",
TEST newURLCalculator(r.URL),
</body> )
</html> 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 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()
}
+10
View File
@@ -0,0 +1,10 @@
ul, ol {
/* remove default agent spacing */
margin: 0;
padding: 0;
list-style: none;
& > li {
display: inline-block;
}
}
@@ -0,0 +1,16 @@
<nav>
<ul>
<li>
<a href="{{ buildSitePath }}">
Home
</a>
</li>
<li>
<a href="{{ buildSitePath "reports" }}">
Reports
</a>
</li>
</ul>
</nav>
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>WIP</title>
<link rel="stylesheet" href="/site/styles/index.css">
<script src="/site/scripts/htmx.min.js.gz"></script>
<script src="/site/scripts/_hyperscript.min.js.gz"></script>
{{/* This is where the page_head template will be inserted into the page */}}
{{- block "head" . }}{{ end }}
</head>
<body>
{{/* This is where the page_body template will be inserted into the page */}}
{{- block "body" . }}{{ end }}
</body>
</html>
@@ -0,0 +1,3 @@
{{ componentBody "nav_bar" }}
<h1>Home</h1>
@@ -0,0 +1,89 @@
{{ componentBody "nav_bar" }}
{{ $storeID := .Request.URL.Query.Get "store-id" }}
<h1>Reports</h1>
<main>
<select
_='
on change
make a URL from window.location.href called currentURL
set currentQuery to searchParams of currentURL
set storeID to event.target.value
js(currentQuery, storeID) currentQuery.set("store-id", storeID) end
set search of currentURL to currentQuery.toString()
set window.location to currentURL
'
>
{{ if not $storeID }}
<option selected disabled>
choose a store
</option>
{{ end }}
<option {{ if eq $storeID "test-store-1" }}selected{{ end }} >
test-store-1
</option>
<option {{ if eq $storeID "test-store-2" }}selected{{ end }} >
test-store-2
</option>
<option {{ if eq $storeID "test-store-3" }}selected{{ end }} >
test-store-3
</option>
</select>
{{- if $storeID }}
<section
style="
display: grid;
grid-template-columns: auto auto auto 1fr;
column-gap: 1em;
row-gap: 0.5em;
margin: 1em 0;
"
>
<div style="border-bottom: solid black 1px;">
Date
</div>
<div style="border-bottom: solid black 1px;">
Time
</div>
<div style="border-bottom: solid black 1px;">
ID
</div>
<div style="border-bottom: solid black 1px;">
Payload
</div>
{{ $events := .RawEvents.LoadEventsForStore "etsy" $storeID }}
{{ range $e := $events }}
<div>
{{ printf "%d/%02d/%02d" $e.EventTimestamp.Year $e.EventTimestamp.Month $e.EventTimestamp.Day }}
</div>
<div>
{{ $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" }}
<span style="color: #{{ $hrColorHex }}{{ $hrColorHex }}{{ $hrColorHex }};">{{ printf "%02d" $hr }}</span>:<span style="color: #{{ $minColorHex }}{{ $minColorHex }}{{ $minColorHex }};">{{ printf "%02d" $ts.Minute }}</span>:<span style="color: #{{ $secColorHex }}{{ $secColorHex }}{{ $secColorHex }};">{{ printf "%02d" $ts.Second }}</span>{{ printf " %s" $suffix }}
</div>
<div>
{{ $e.EventID }}
</div>
<div>
{{ prettyPrintJSON $e.Payload }}
</div>
{{ end }}
</section>
{{- end }}
</main>
+9 -1
View File
@@ -21,9 +21,17 @@ func NewWebhookHandler(db *raw_events.Store) http.Handler {
ts := time.Now().UTC() 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{ err := db.Save(r.Context(), &raw_events.Event{
Platform: "etsy", Platform: "etsy",
StoreID: "test-store-1", StoreID: storeID,
EventID: fmt.Sprint(ts.Unix()), EventID: fmt.Sprint(ts.Unix()),
EventTimestamp: ts, EventTimestamp: ts,
Payload: body, Payload: body,
+4 -3
View File
@@ -73,7 +73,7 @@ func runApp(ctx context.Context) error {
func runServer(ctx context.Context, db *raw_events.Store) <-chan error { func runServer(ctx context.Context, db *raw_events.Store) <-chan error {
srv := &http.Server{ srv := &http.Server{
Addr: ":9000", // local Addr: ":8082", // local
Handler: buildHTTPHandler(db), Handler: buildHTTPHandler(db),
} }
@@ -86,7 +86,7 @@ func runServer(ctx context.Context, db *raw_events.Store) <-chan error {
defer cancel() defer cancel()
defer close(alreadyShutdownCh) defer close(alreadyShutdownCh)
fmt.Println("server running on 9000...") fmt.Println("server running on 8082...")
if err := srv.ListenAndServe(); err != nil { if err := srv.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) { if !errors.Is(err, http.ErrServerClosed) {
runningErrCh <- fmt.Errorf("server experienced error: %w", err) runningErrCh <- fmt.Errorf("server experienced error: %w", err)
@@ -129,7 +129,8 @@ func buildHTTPHandler(db *raw_events.Store) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(db))) 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 return mux
} }