prototype report page
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user