commit cdbc08dea2c922bd52a753bce2d4563763e9a2d0 Author: Angel Beltran Date: Tue Dec 23 22:35:33 2025 -0700 site and webhooks packages stubbed diff --git a/README.md b/README.md new file mode 100644 index 0000000..c619c2c --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# V2 - Attempt 2 + + +Looking to follow the [CQRS](https://martinfowler.com/bliki/CQRS.html) pattern. + +The database will follow the [event sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) database pattern. +All events (or commands) will be stored in a respective event series, and all database reads will be from views that are projections, reductions, aggregations of those event series. + + +## Application/directory structure +- /internal + - /site: website + - /webhooks: webhooks for platform events + - /domains: packages for each domain + - /store_events: storing and events + - /platforms: ecommerce platform domains + - /tiktok: interface with tiktok + - ... etc + - ... etc + +![Application structure](./diagrams/application_structure.svg) + + +## Architectural and Software Diagrams + + +### Event Sourcing Architecture +![Event sourcing architecture](./diagrams/event_sourcing.svg) + + +### Event Structure + +![Event](./diagrams/event.svg) + + +### Store Event Database Tables + +![Event database tables](./diagrams/event_tables.svg) + + +### All Diagrams + +All diagrams are stored in the [diagrams](./diagrams) directory + diff --git a/diagrams/application_structure.svg b/diagrams/application_structure.svg new file mode 100644 index 0000000..cc95b34 --- /dev/null +++ b/diagrams/application_structure.svg @@ -0,0 +1 @@ +http serversitewebhooksdomainsWeb siteTiktok APIEtsy APIEtc APIsREST APIHTML TemplatesTiktok HookEtsy HookEtc Hooksstore eventstiktoketsyetc \ No newline at end of file diff --git a/diagrams/application_structure.uml b/diagrams/application_structure.uml new file mode 100644 index 0000000..c497eed --- /dev/null +++ b/diagrams/application_structure.uml @@ -0,0 +1,49 @@ +@startuml + + +cloud { + [Web site] +} + +cloud { + [Tiktok API] + [Etsy API] + [Etc APIs] +} + +node "http server" { + frame "site" { + [REST API] + [HTML Templates] + } + frame "webhooks" { + [Tiktok Hook] + [Etsy Hook] + [Etc Hooks] + } +} + +node "domains" { + [store events] + [tiktok] + [etsy] + [etc] +} + +[Web site] -- [REST API] +[REST API] -- [HTML Templates] +[REST API] -- [store events] +[REST API] -- [tiktok] +[REST API] -- [etsy] +[REST API] -- [etc] + +[Tiktok API] -- [Tiktok Hook] +[Etsy API] -- [Etsy Hook] +[Etc APIs] -- [Etc Hooks] + +[Tiktok Hook] -- [tiktok] +[Etsy Hook] -- [etsy] +[Etc Hooks] -- [etc] + + +@enduml diff --git a/diagrams/event.svg b/diagrams/event.svg new file mode 100644 index 0000000..f918ffa --- /dev/null +++ b/diagrams/event.svg @@ -0,0 +1,46 @@ +platformOrderEventHTTP RequestHeadersBodyevent descriptionstoreidtimestamporder \ No newline at end of file diff --git a/diagrams/event.uml b/diagrams/event.uml new file mode 100644 index 0000000..06914af --- /dev/null +++ b/diagrams/event.uml @@ -0,0 +1,28 @@ +@startuml + + +object Event +object "HTTP Request" as HTTPRequest +object Headers +object Body +object "event description" as EventDescription +cloud platform { + object Order +} + + +Event --> HTTPRequest +HTTPRequest *-- Headers +HTTPRequest *-- Body +Body *-- EventDescription + +EventDescription : store +EventDescription : id +EventDescription : timestamp +EventDescription : order + +EventDescription <|- Order + + + +@enduml diff --git a/diagrams/event_sourcing.svg b/diagrams/event_sourcing.svg new file mode 100644 index 0000000..f736795 --- /dev/null +++ b/diagrams/event_sourcing.svg @@ -0,0 +1,51 @@ +db (postgres)viewsraw eventstriggervirtualmaterialecommerce platforms:A, B, C, ...webhookeventsavereadupdate \ No newline at end of file diff --git a/diagrams/event_sourcing.uml b/diagrams/event_sourcing.uml new file mode 100644 index 0000000..f9dff2a --- /dev/null +++ b/diagrams/event_sourcing.uml @@ -0,0 +1,25 @@ +@startuml + + +cloud "ecommerce platforms:\n A, B, C, ..." as platforms +boundary webhook +database db as "db (postgres)" { + rectangle "raw events" as eventtable + control trigger + + rectangle views { + rectangle virtual as virtualViews + rectangle material as materialViews + } +} + + +platforms -> webhook : event +webhook -> eventtable : save +eventtable -> trigger : read +trigger -up-> materialViews : update +eventtable -up-> virtualViews +materialViews -left-> virtualViews + + +@enduml diff --git a/diagrams/event_tables.svg b/diagrams/event_tables.svg new file mode 100644 index 0000000..6db4ff4 --- /dev/null +++ b/diagrams/event_tables.svg @@ -0,0 +1 @@ +store_eventsplatformstore_idevent_idevent_timestamptiktok_store_idtiktok_event_id<platform B>_store_id<platform B>_event_id<platform C>_store_id<platform C>_event_id<platform ..>_store_id<platform ..>_event_idraw payload?reference tiktok_store_event_detailsreference <platform B>_store_event_detailsreference <platform C>_store_event_detailsonly one reference can exist at a time(eg event CANNOT be both from tiktok AND etsy)tiktok_store_event_detailsstore_idevent_idevent_timestamp[attribute: value ...]<platform B>_store_event_detailsstore_idevent_idevent_timestamp[attribute: value ...]<platform C>_store_event_detailsstore_idevent_idevent_timestamp[attribute: value ...] \ No newline at end of file diff --git a/diagrams/event_tables.uml b/diagrams/event_tables.uml new file mode 100644 index 0000000..5ccaf47 --- /dev/null +++ b/diagrams/event_tables.uml @@ -0,0 +1,56 @@ +@startuml + + +object store_events +store_events : platform +store_events : store_id +store_events : event_id +store_events : event_timestamp +store_events : tiktok_store_id +store_events : tiktok_event_id +store_events : _store_id +store_events : _event_id +store_events : _store_id +store_events : _event_id +store_events : _store_id +store_events : _event_id +store_events : raw payload? + +note right of store_events::"tiktok_event_id" + reference tiktok_store_event_details +end note +note right of store_events::"_event_id" + reference _store_event_details +end note +note right of store_events::"_event_id" + reference _store_event_details +end note +note right of store_events::"_event_id" + only one reference can exist at a time + (eg event CANNOT be both from tiktok AND etsy) +end note + +object tiktok_store_event_details +tiktok_store_event_details : store_id +tiktok_store_event_details : event_id +tiktok_store_event_details : event_timestamp +tiktok_store_event_details : [attribute: value ...] + +object "_store_event_details" as platform_b_specific_store_event_details +platform_b_specific_store_event_details : store_id +platform_b_specific_store_event_details : event_id +platform_b_specific_store_event_details : event_timestamp +platform_b_specific_store_event_details : [attribute: value ...] + +object "_store_event_details" as platform_c_specific_store_event_details +platform_c_specific_store_event_details : store_id +platform_c_specific_store_event_details : event_id +platform_c_specific_store_event_details : event_timestamp +platform_c_specific_store_event_details : [attribute: value ...] + +store_events <|-- tiktok_store_event_details +store_events <|-- platform_b_specific_store_event_details +store_events <|-- platform_c_specific_store_event_details + + +@enduml diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bd99d29 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ruben/inventory2 + +go 1.24.2 diff --git a/internal/site/site.go b/internal/site/site.go new file mode 100644 index 0000000..4c8b836 --- /dev/null +++ b/internal/site/site.go @@ -0,0 +1,26 @@ +package site + +import ( + "net/http" +) + +func NewSiteHandler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + + + + TEST + + + TEST + + +`)) + }) + + return mux +} diff --git a/internal/webhooks/etsy/webhooks.go b/internal/webhooks/etsy/webhooks.go new file mode 100644 index 0000000..7a64a3e --- /dev/null +++ b/internal/webhooks/etsy/webhooks.go @@ -0,0 +1,17 @@ +package etsy + +import ( + "fmt" + "net/http" +) + +func NewWebhookHandler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(fmt.Sprintf("received etsy webhook call: %s %s\n", r.Method, r.URL))) + }) + + return mux +} diff --git a/internal/webhooks/tiktok/webhooks.go b/internal/webhooks/tiktok/webhooks.go new file mode 100644 index 0000000..f6e38d9 --- /dev/null +++ b/internal/webhooks/tiktok/webhooks.go @@ -0,0 +1,17 @@ +package tiktok + +import ( + "fmt" + "net/http" +) + +func NewWebhookHandler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(fmt.Sprintf("received tiktok webhook call: %s %s\n", r.Method, r.URL))) + }) + + return mux +} diff --git a/internal/webhooks/webhooks.go b/internal/webhooks/webhooks.go new file mode 100644 index 0000000..b145cf8 --- /dev/null +++ b/internal/webhooks/webhooks.go @@ -0,0 +1,19 @@ +package webhooks + +import ( + "net/http" + + "ruben/inventory2/internal/webhooks/etsy" + "ruben/inventory2/internal/webhooks/tiktok" + "ruben/inventory2/internal/webhooks/wix" +) + +func New() http.Handler { + wh := http.NewServeMux() + + wh.Handle("/etsy/", http.StripPrefix("/etsy", etsy.NewWebhookHandler())) + wh.Handle("/tiktok/", http.StripPrefix("/tiktok", tiktok.NewWebhookHandler())) + wh.Handle("/wix/", http.StripPrefix("/wix", wix.NewWebhookHandler())) + + return wh +} diff --git a/internal/webhooks/wix/webhooks.go b/internal/webhooks/wix/webhooks.go new file mode 100644 index 0000000..8f4e3c5 --- /dev/null +++ b/internal/webhooks/wix/webhooks.go @@ -0,0 +1,17 @@ +package wix + +import ( + "fmt" + "net/http" +) + +func NewWebhookHandler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(fmt.Sprintf("received wix webhook call: %s %s\n", r.Method, r.URL))) + }) + + return mux +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f8a137d --- /dev/null +++ b/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "ruben/inventory2/internal/site" + "ruben/inventory2/internal/webhooks" +) + +func main() { + if err := runApp(context.Background()); err != nil { + panic(err) + } + fmt.Println("application shutdown") +} + +func runApp(ctx context.Context) error { + ctx, shutdown := context.WithCancel(ctx) + defer shutdown() + + // start http server + + srvErrCh := runServer(ctx) + + // wait for interrupt signal or unrecoverable failure, then shutdown + + osSignalCh := make(chan os.Signal, 1) + signal.Notify(osSignalCh, syscall.SIGINT, syscall.SIGTERM) + + var serverAlreadyShutdown bool + select { + case s := <-osSignalCh: + fmt.Println("application received shutdown signal:", s) + fmt.Println("shutting down") + case err := <-srvErrCh: + serverAlreadyShutdown = true + fmt.Println("server shutdown unexpectedly") + if err != nil { + fmt.Println("server encountered error:", err) + } + } + + shutdown() + + // capture application errors that occurred during or caused shutdown + + var errs []error + + if !serverAlreadyShutdown { + if err := <-srvErrCh; err != nil { + errs = append(errs, fmt.Errorf("server experienced an error: %w", err)) + } + fmt.Println("server shut down") + } + + return errors.Join(errs...) +} + +func buildHTTPHandler() http.Handler { + mux := http.NewServeMux() + + mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New())) + mux.Handle("/site/", http.StripPrefix("/site", site.NewSiteHandler())) + + return mux +} + +func runServer(ctx context.Context) <-chan error { + srv := &http.Server{ + Addr: ":9000", // local + Handler: buildHTTPHandler(), + } + + ctx, cancel := context.WithCancel(ctx) + alreadyShutdownCh := make(chan struct{}, 1) + + runningErrCh := make(chan error, 1) + go func() { + defer close(runningErrCh) + defer cancel() + defer close(alreadyShutdownCh) + + fmt.Println("server running on 9000...") + if err := srv.ListenAndServe(); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + runningErrCh <- fmt.Errorf("server experienced error: %w", err) + } + } + }() + + shutdownErrCh := make(chan error, 1) + go func() { + defer close(shutdownErrCh) + + select { + case <-alreadyShutdownCh: + return + case <-ctx.Done(): + } + + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelShutdown() + if err := srv.Shutdown(shutdownCtx); err != nil { + shutdownErrCh <- fmt.Errorf("error occurred attempting to shutdown server: %w", err) + } + }() + + errCh := make(chan error, 1) + go func() { + defer close(errCh) + + err1 := <-runningErrCh + err2 := <-shutdownErrCh + if err := errors.Join(err1, err2); err != nil { + errCh <- err + } + }() + + return errCh +}