diff --git a/README.md b/README.md
index e9053d2..8919094 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,11 @@
# Roadmap
+- [ ] Etsy (WIP)
+ - [ ] GET ETSY AUTH (WIP)
+ - [ ] move auth state stuff to database (out of cache)
+ - [ ] Get new access token using refresh token flow
+ - [ ] Get new refresh token flow
- [ ] Complete this design document?
- [ ] Complete defining this roadmap checklist
- [ ] Website displaying an audit of store events
@@ -51,13 +56,19 @@ All events (or commands) will be stored in a respective event series, and all da

-## Website heirarchy
+## Website hierarchy
- /site
-## Architectural and Software Diagrams
+# Architectural and Software Diagrams
+## Database schema
+
+
+
+
+## Events
### Event Sourcing Architecture

@@ -73,6 +84,26 @@ All events (or commands) will be stored in a respective event series, and all da

-### All Diagrams
+## Platform: Etsy
+
+### Signing up
+
+**TODO: need an account page that can create accounts ahead of time - force users to create an account first!**
+
+
+
+***TODO: create a page that will take billing information and include it in this process***
+
+### Getting a new refresh token
+
+***TODO***
+
+
+### Models
+
+
+
+
+## All Diagrams
All diagrams are stored in the [diagrams](./diagrams) directory
diff --git a/database.go b/database.go
new file mode 100644
index 0000000..d85c975
--- /dev/null
+++ b/database.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func newPool(ctx context.Context) (*pgxpool.Pool, error) {
+ pool, err := pgxpool.New(ctx, "postgres://app_client:app_password@localhost:5432/inventory_2?sslmode=disable")
+ if err != nil {
+ return nil, fmt.Errorf("failed to create database client: %w", err)
+ }
+
+ conn, err := pool.Acquire(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create a database connection: %w", err)
+ }
+ conn.Release()
+
+ return pool, nil
+}
diff --git a/database_migrations/000007_accounts.down.sql b/database_migrations/000007_accounts.down.sql
new file mode 100644
index 0000000..2b201b7
--- /dev/null
+++ b/database_migrations/000007_accounts.down.sql
@@ -0,0 +1 @@
+DROP TABLE accounts CASCADE;
diff --git a/database_migrations/000007_accounts.up.sql b/database_migrations/000007_accounts.up.sql
new file mode 100644
index 0000000..e8ace34
--- /dev/null
+++ b/database_migrations/000007_accounts.up.sql
@@ -0,0 +1,9 @@
+CREATE TABLE accounts (
+ account_id SERIAL NOT NULL,
+ email TEXT NOT NULL,
+ verified BOOLEAN NOT NULL DEFAULT FALSE,
+
+ PRIMARY KEY (account_id)
+);
+
+CREATE UNIQUE INDEX ON accounts (email);
diff --git a/database_migrations/000008_etsy_access_tokens.down.sql b/database_migrations/000008_etsy_access_tokens.down.sql
new file mode 100644
index 0000000..5cf2b37
--- /dev/null
+++ b/database_migrations/000008_etsy_access_tokens.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE etsy_access_tokens;
+DROP TABLE etsy_users;
diff --git a/database_migrations/000008_etsy_access_tokens.up.sql b/database_migrations/000008_etsy_access_tokens.up.sql
new file mode 100644
index 0000000..307becb
--- /dev/null
+++ b/database_migrations/000008_etsy_access_tokens.up.sql
@@ -0,0 +1,19 @@
+CREATE TABLE etsy_users (
+ account_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ store_id INTEGER NOT NULL,
+
+ PRIMARY KEY (user_id),
+ FOREIGN KEY (account_id) REFERENCES accounts
+);
+
+CREATE TABLE etsy_access_tokens (
+ user_id INTEGER NOT NULL,
+ access_token TEXT NOT NULL,
+ refresh_token TEXT NOT NULL,
+ access_token_expiration TIMESTAMPTZ NOT NULL,
+ refresh_token_expiration TIMESTAMPTZ NOT NULL,
+
+ PRIMARY KEY (user_id),
+ FOREIGN KEY (user_id) REFERENCES etsy_users
+);
diff --git a/database_migrations/000009_etsy_oauth_requests.down.sql b/database_migrations/000009_etsy_oauth_requests.down.sql
new file mode 100644
index 0000000..a669136
--- /dev/null
+++ b/database_migrations/000009_etsy_oauth_requests.down.sql
@@ -0,0 +1 @@
+DROP TABLE etsy_oauth_requests CASCADE;
diff --git a/database_migrations/000009_etsy_oauth_requests.up.sql b/database_migrations/000009_etsy_oauth_requests.up.sql
new file mode 100644
index 0000000..5a9ab5c
--- /dev/null
+++ b/database_migrations/000009_etsy_oauth_requests.up.sql
@@ -0,0 +1,11 @@
+CREATE TABLE etsy_oauth_requests (
+ account_id INTEGER NOT NULL,
+ state BYTEA NOT NULL,
+ code_verifier BYTEA NOT NULL,
+ expiration TIMESTAMPTZ NOT NULL,
+
+ PRIMARY KEY (state),
+ FOREIGN KEY (account_id) REFERENCES accounts
+);
+
+CREATE UNIQUE INDEX ON etsy_oauth_requests (state);
diff --git a/diagrams/database_schema.svg b/diagrams/database_schema.svg
new file mode 100644
index 0000000..2a148f2
--- /dev/null
+++ b/diagrams/database_schema.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/diagrams/database_schema.uml b/diagrams/database_schema.uml
new file mode 100644
index 0000000..4837f53
--- /dev/null
+++ b/diagrams/database_schema.uml
@@ -0,0 +1,94 @@
+@startuml
+hide circle
+skinparam linetype ortho
+
+entity "**accounts**" {
+ + ""account_id"": //serial [PK]//
+ --
+ *""email"": //text //
+ *""verified"": //boolean //
+}
+
+entity "**etsy_access_tokens**" {
+ + ""user_id"": //integer [PK][FK]//
+ --
+ *""access_token"": //text //
+ *""refresh_token"": //text //
+ *""access_token_expiration"": //timestamp with time zone //
+ *""refresh_token_expiration"": //timestamp with time zone //
+}
+
+entity "**etsy_store_events**" {
+ + ""store_id"": //text [PK][FK]//
+ + ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ + ""event_id"": //text [PK][FK]//
+ --
+ *""platform"": //text [FK]//
+}
+
+entity "**etsy_users**" {
+ + ""user_id"": //integer [PK]//
+ --
+ *""account_id"": //integer [FK]//
+ *""shop_id"": //integer //
+}
+
+entity "**raw_store_events**" {
+ + ""platform"": //text [PK]//
+ + ""store_id"": //text [PK]//
+ + ""event_timestamp"": //timestamp with time zone [PK]//
+ + ""event_id"": //text [PK]//
+ --
+ *""raw_payload"": //jsonb //
+}
+
+entity "**schema_migrations**" {
+ + ""version"": //bigint [PK]//
+ --
+ *""dirty"": //boolean //
+}
+
+entity "**tiktok_store_events**" {
+ + ""store_id"": //text [PK][FK]//
+ + ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ + ""event_id"": //text [PK][FK]//
+ --
+ *""platform"": //text [FK]//
+}
+
+entity "**wix_store_events**" {
+ + ""store_id"": //text [PK][FK]//
+ + ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ + ""event_id"": //text [PK][FK]//
+ --
+ *""platform"": //text [FK]//
+}
+
+"**etsy_access_tokens**" ||-|| "**etsy_users**"
+
+"**etsy_store_events**" }-- "**raw_store_events**"
+
+"**etsy_store_events**" }-- "**raw_store_events**"
+
+"**etsy_store_events**" }-- "**raw_store_events**"
+
+"**etsy_store_events**" }-- "**raw_store_events**"
+
+"**etsy_users**" }-- "**accounts**"
+
+"**tiktok_store_events**" }-- "**raw_store_events**"
+
+"**tiktok_store_events**" }-- "**raw_store_events**"
+
+"**tiktok_store_events**" }-- "**raw_store_events**"
+
+"**tiktok_store_events**" }-- "**raw_store_events**"
+
+"**wix_store_events**" }-- "**raw_store_events**"
+
+"**wix_store_events**" }-- "**raw_store_events**"
+
+"**wix_store_events**" }-- "**raw_store_events**"
+
+"**wix_store_events**" }-- "**raw_store_events**"
+@enduml
diff --git a/diagrams/etsy/models.svg b/diagrams/etsy/models.svg
new file mode 100644
index 0000000..02143b8
--- /dev/null
+++ b/diagrams/etsy/models.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/diagrams/etsy/models.uml b/diagrams/etsy/models.uml
new file mode 100644
index 0000000..4148059
--- /dev/null
+++ b/diagrams/etsy/models.uml
@@ -0,0 +1,15 @@
+@startuml
+
+
+class User
+
+class Shop
+
+User o-- Shop
+
+'class UserAddress
+
+'User o-- UserAddress
+
+
+@enduml
diff --git a/diagrams/etsy/obtaining_access_token.svg b/diagrams/etsy/obtaining_access_token.svg
new file mode 100644
index 0000000..950c7a3
--- /dev/null
+++ b/diagrams/etsy/obtaining_access_token.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/diagrams/etsy/obtaining_access_token.uml b/diagrams/etsy/obtaining_access_token.uml
new file mode 100644
index 0000000..1602581
--- /dev/null
+++ b/diagrams/etsy/obtaining_access_token.uml
@@ -0,0 +1,41 @@
+@startuml
+
+
+actor User
+boundary AccountPage
+boundary EtsyPage
+control AppServer
+database DB
+control Etsy
+
+
+User -> AccountPage ++ : load
+AccountPage -> AppServer ++ : get link to etsy to give app access
+AppServer -> DB ++ : TODO: store oauth state and code
+AppServer <-- DB --
+AccountPage <-- AppServer -- : link
+User -> AccountPage : click link
+User <-- AccountPage -- : redirect
+
+User -> EtsyPage ++ : load
+User <-- EtsyPage
+User -> EtsyPage : click button to "give app access"
+EtsyPage -> Etsy ++
+AppServer <- Etsy ++ : send auth code
+AppServer -> DB ++ : get oauth state
+AppServer <-- DB --
+AppServer -> AppServer : verify state
+AppServer -> Etsy ++ : request access code
+AppServer <-- Etsy -- : access token and expiration, refresh token, and user_id
+AppServer -> Etsy ++ : get user's store
+AppServer <-- Etsy --
+AppServer -> DB ++ : save access token, store id, etc
+AppServer <-- DB --
+AppServer --> Etsy --
+EtsyPage <-- Etsy : 3xx redirect to app's redirect url (account page)
+User <-- EtsyPage --
+
+User -> AccountPage : load
+
+
+@enduml
diff --git a/diagrams/event.svg b/diagrams/event.svg
index f918ffa..3937b62 100644
--- a/diagrams/event.svg
+++ b/diagrams/event.svg
@@ -1,46 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/diagrams/event_sourcing.svg b/diagrams/event_sourcing.svg
index f736795..ade949f 100644
--- a/diagrams/event_sourcing.svg
+++ b/diagrams/event_sourcing.svg
@@ -1,51 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/diagrams/event_tables.svg b/diagrams/event_tables.svg
index 55297ff..c54edf8 100644
--- a/diagrams/event_tables.svg
+++ b/diagrams/event_tables.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/go.mod b/go.mod
index abffe27..b12a9fc 100644
--- a/go.mod
+++ b/go.mod
@@ -8,26 +8,32 @@ require github.com/jackc/pgx/v5 v5.7.6
require (
github.com/angelbeltran/templater v0.1.0
+ github.com/google/uuid v1.5.0
github.com/oapi-codegen/runtime v1.1.2
)
require (
+ github.com/achiku/planter v0.0.0-20240123065711-dff6de0e438e // indirect
+ github.com/alecthomas/kingpin v2.2.6+incompatible // indirect
+ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
+ github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
- github.com/google/uuid v1.5.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
+ github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
@@ -42,4 +48,7 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
+tool (
+ github.com/achiku/planter
+ github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
+)
diff --git a/go.sum b/go.sum
index 89276f9..4cdbebb 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,12 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/achiku/planter v0.0.0-20240123065711-dff6de0e438e h1:21CBUAcoqBaU42GVG3xIC1ckeOWtZbqSA4NxAyei9CU=
+github.com/achiku/planter v0.0.0-20240123065711-dff6de0e438e/go.mod h1:ajWKEzGTqo7y5QInkqZJIU2kPwxfmJmKPMkWBiAv2y4=
+github.com/alecthomas/kingpin v2.2.6+incompatible h1:5svnBTFgJjZvGKyYBtMB0+m5wvrbUHiqye8wRJMlnYI=
+github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs=
+github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
@@ -61,6 +69,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
@@ -90,6 +100,8 @@ github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
diff --git a/internal/consts/errors.go b/internal/consts/errors.go
new file mode 100644
index 0000000..5e2dea1
--- /dev/null
+++ b/internal/consts/errors.go
@@ -0,0 +1,8 @@
+package consts
+
+import "errors"
+
+var (
+ // TODO: use throught the database methods and the site template parsing to treat it as a 404 (not a redirect)
+ ErrNotFound = errors.New("not found")
+)
diff --git a/internal/domains/accounts/accounts.go b/internal/domains/accounts/accounts.go
new file mode 100644
index 0000000..3785200
--- /dev/null
+++ b/internal/domains/accounts/accounts.go
@@ -0,0 +1,125 @@
+package accounts
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "ruben/inventory2/internal/consts"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type (
+ Store struct {
+ db *pgxpool.Pool
+ }
+
+ StoreWithContext struct {
+ ctx context.Context
+ db *Store
+ }
+
+ Account struct {
+ ID int64
+ Email string
+ }
+)
+
+func NewStore(db *pgxpool.Pool) *Store {
+ return &Store{
+ db: db,
+ }
+}
+
+func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
+ return &StoreWithContext{
+ ctx: ctx,
+ db: db,
+ }
+}
+
+func (db *Store) CreateAccount(ctx context.Context, email string) (Account, error) {
+ rows, err := db.db.Query(
+ ctx,
+ "INSERT INTO accounts (email) VALUES (@email) RETURNING account_id",
+ pgx.NamedArgs{
+ "email": email,
+ },
+ )
+ if err != nil {
+ return Account{}, fmt.Errorf("failed to perform query: %w", err)
+ }
+
+ acctID, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
+ if err != nil {
+ return Account{}, fmt.Errorf("failed to scan row: %w", err)
+ }
+
+ return Account{
+ ID: acctID,
+ Email: email,
+ }, nil
+}
+
+func (db *Store) GetAccount(ctx context.Context, id int64) (Account, error) {
+ rows, err := db.db.Query(
+ ctx,
+ "SELECT email FROM accounts WHERE account_id = @account_id",
+ pgx.NamedArgs{
+ "account_id": id,
+ },
+ )
+ if err != nil {
+ return Account{}, fmt.Errorf("failed to perform query: %w", err)
+ }
+
+ email, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[string])
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Account{}, consts.ErrNotFound
+ }
+
+ return Account{}, fmt.Errorf("failed to scan row: %w", err)
+ }
+
+ return Account{
+ ID: id,
+ Email: email,
+ }, nil
+}
+
+func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account, error) {
+ rows, err := db.db.Query(
+ ctx,
+ "SELECT account_id FROM accounts WHERE email = @email",
+ pgx.NamedArgs{
+ "email": email,
+ },
+ )
+ if err != nil {
+ return Account{}, fmt.Errorf("failed to perform query: %w", err)
+ }
+
+ id, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Account{}, consts.ErrNotFound
+ }
+
+ return Account{}, fmt.Errorf("failed to scan row: %w", err)
+ }
+
+ return Account{
+ ID: id,
+ Email: email,
+ }, nil
+}
+
+func (db *StoreWithContext) CreateAccount(email string) (Account, error) {
+ return db.db.CreateAccount(db.ctx, email)
+}
+
+func (db *StoreWithContext) GetAccount(id int64) (Account, error) {
+ return db.db.GetAccount(db.ctx, id)
+}
diff --git a/internal/domains/platforms/etsy/client.go b/internal/domains/platforms/etsy/client.go
new file mode 100644
index 0000000..bf168fb
--- /dev/null
+++ b/internal/domains/platforms/etsy/client.go
@@ -0,0 +1,47 @@
+package etsy
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "ruben/inventory2/internal/domains/platforms/etsy/generated_client"
+)
+
+func newFixedAccessTokenClient(apiKey, accessToken string) (*generated_client.ClientWithResponses, error) {
+ return newAuthenticatingClient(apiKey, func(ctx context.Context) (string, error) {
+ return accessToken, nil
+ })
+}
+
+func newAccessTokenRefreshingClient(apiKey string) (*generated_client.ClientWithResponses, error) {
+ return newAuthenticatingClient(apiKey, func(ctx context.Context) (string, error) {
+ // TODO: look up the token in the db.
+ return "", nil
+ })
+}
+
+func newAuthenticatingClient(apiKey string, getAccessToken func(context.Context) (string, error)) (*generated_client.ClientWithResponses, error) {
+ var setAuthHeaders generated_client.ClientOption = func(c *generated_client.Client) error {
+ c.RequestEditors = append(c.RequestEditors, generated_client.RequestEditorFn(func(ctx context.Context, req *http.Request) error {
+ accessToken, err := getAccessToken(req.Context())
+ if err != nil {
+ return err
+ }
+
+ h := req.Header
+ h.Set("x-api-key", apiKey)
+ h.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
+
+ return nil
+ }))
+ return nil
+ }
+
+ c, err := generated_client.NewClientWithResponses("https://api.etsy.com", setAuthHeaders)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize open api client: %w", err)
+ }
+
+ return c, nil
+}
diff --git a/internal/domains/platforms/etsy/etsy.go b/internal/domains/platforms/etsy/etsy.go
index c2c474b..fddcbcf 100644
--- a/internal/domains/platforms/etsy/etsy.go
+++ b/internal/domains/platforms/etsy/etsy.go
@@ -1,19 +1,320 @@
package etsy
-//go:generate oapi-codegen -package generated_client -o generated_client/client.go openapi.3.0.2.json
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
-const (
- etsyAPIKeystring = "38ncokqh0jih5jshfk8iv4n5"
- etsyAPISharedSecret = "jaaw0tyizf"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "ruben/inventory2/internal/domains/platforms/etsy/generated_client"
)
-func GetEtsyAPIKeystring() string {
- return etsyAPIKeystring
+//go:generate oapi-codegen -generate types,client -package generated_client -o generated_client/client.go openapi.3.0.2.json
+
+// TODO:
+// - [x] document the flow in the README.md
+// - [ ] save the initial access/refresh tokens in the db
+// - [ ] make calls to refresh the access token and store it in the db.
+// - [ ] make cron jobs to automatically refresh the refresh token upon as tokens approach expiration.
+// - [ ] make pages to direct to the etsy shop acceptance url/page
+// - [ ] stub an account page
+
+type (
+ Platform struct {
+ oAuthRedirectURI func(acctID int64) string
+ apiKeystring string
+ apiSharedSecret string
+ db *pgxpool.Pool
+ }
+
+ PlatformWithContext struct {
+ ctx context.Context
+ p *Platform
+ }
+)
+
+// oauth scopes
+const (
+ scopeAddressRead = "address_r" // Read a member's shipping addresses.
+ scopeAddressWrite = "address_w" // Update and delete a member's shipping address.
+ scopeBillingRead = "billing_r" // Read a member's Etsy bill charges and payments.
+ scopeCartRead = "cart_r" // Read the contents of a member’s cart.
+ scopeCartWrite = "cart_w" // Add and remove listings from a member's cart.
+ scopeEmailRead = "email_r" // Read a user profile
+ scopeFavoritesRead = "favorites_r" // View a member's favorite listings and users.
+ scopeFavoritesWrite = "favorites_w" // Add to and remove from a member's favorite listings and users.
+ scopeFeedbackRead = "feedback_r" // View all details of a member's feedback (including purchase history.)
+ scopeListings_d = "listings_d" // Delete a member's listings.
+ scopeListingsRead = "listings_r" // Read a member's inactive and expired (i.e., non-public) listings.
+ scopeListingsWrite = "listings_w" // Create and edit a member's listings.
+ scopeProfileRead = "profile_r" // Read a member's private profile information.
+ scopeProfileWrite = "profile_w" // Update a member's private profile information.
+ scopeRecommendRead = "recommend_r" // View a member's recommended listings.
+ scopeRecommendWrite = "recommend_w" // Remove a member's recommended listings.
+ scopeShopsRead = "shops_r" // See a member's shop description, messages and sections, even if not (yet) public.
+ scopeShopsWrite = "shops_w" // Update a member's shop description, messages and sections.
+ scopeTransactionsRead = "transactions_r" // Read a member's purchase and sales data. This applies to buyers as well as sellers.
+ scopeTransactionsWrite = "transactions_w" // Update a member's sales data.
+)
+
+func NewPlatform(oAuthRedirectURI func(acctID int64) string, apiKeystring, apiSharedSecret string, db *pgxpool.Pool) *Platform {
+ return &Platform{
+ oAuthRedirectURI: oAuthRedirectURI,
+ apiKeystring: apiKeystring,
+ apiSharedSecret: apiSharedSecret,
+ db: db,
+ }
}
-func GetEtsyAPISharedSecret() string {
- return etsyAPISharedSecret
+func (p *Platform) WithContext(ctx context.Context) *PlatformWithContext {
+ return &PlatformWithContext{
+ ctx: ctx,
+ p: p,
+ }
}
-func requestAnAuthCode() {
+func (p *Platform) GenerateConnectionURLForNewAccount(ctx context.Context, acctID int64) (*url.URL, error) {
+ req, err := p.createNewOAuthRequest(ctx, acctID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create oauth request parameters: %w", err)
+ }
+
+ state := req.state
+ code := req.pkceCode
+
+ return &url.URL{
+ Scheme: "https",
+ Host: "www.etsy.com",
+ Path: "/oauth/connect",
+ RawQuery: url.Values{
+ "response_type": {"code"},
+ "redirect_uri": {p.oAuthRedirectURI(acctID)},
+ "scope": {url.QueryEscape(strings.Join([]string{
+ scopeCartRead,
+ scopeCartWrite,
+ scopeEmailRead,
+ scopeListingsWrite,
+ }, ","))},
+ "client_id": {p.apiKeystring},
+ "state": {state.String()},
+ "code_challenge": {fmt.Sprintf("%x", code.challenge)},
+ "code_challenge_method": {"S256"},
+ }.Encode(),
+ }, nil
+}
+
+func (p *PlatformWithContext) GenerateConnectionURLForNewAccount(acctID int64) (*url.URL, error) {
+ return p.p.GenerateConnectionURLForNewAccount(p.ctx, acctID)
+}
+
+// HandleNewAuthCode handles the auth code to get api access
+func (p *Platform) HandleNewAuthCode(ctx context.Context, acctID int64, state, authCode string) (bool, error) {
+ // look up matching oauth request
+
+ stateUUID, err := uuid.Parse(state)
+ if err != nil {
+ return false, nil
+ }
+
+ oar, ok, err := p.getOauthRequest(ctx, stateUUID)
+ if err != nil {
+ return false, fmt.Errorf("failed to look up existing oauth request: %w", err)
+ }
+ if !ok {
+ return false, nil
+ }
+ if oar.acctID != acctID {
+ return false, p.deleteOauthRequest(ctx, stateUUID)
+ }
+
+ // construct http request to obtain access token
+
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ (&url.URL{
+ Scheme: "https://",
+ Host: "api.etsy.com",
+ Path: "/v3/public/oauth/token",
+ }).String(),
+ bytes.NewReader([]byte(url.Values{
+ "grant_type": {"authorization_code"},
+ "client_id": {p.apiKeystring},
+ "redirect_uri": {p.oAuthRedirectURI(acctID)},
+ "code": {authCode},
+ "code_verifier": {fmt.Sprintf("%x", oar.pkceCode.verifier)},
+ }.Encode())),
+ )
+ if err != nil {
+ return false, fmt.Errorf("failed to generate http request to get oauth tokens: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "x-www-form-urlencoded")
+
+ // perform request to obtain access token
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return false, fmt.Errorf("failed to perform http request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ b, err := io.ReadAll(resp.Body)
+ if err != nil && !errors.Is(err, io.EOF) {
+ return false, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if c := resp.StatusCode; (c / 100) != 2 {
+ return false, fmt.Errorf("unexpected http response code: %d; body = %s", c, string(b))
+ }
+
+ // parse and validate response body
+
+ accessToken, refreshToken, expiration, userID, err := p.parseAccessCodeResponseBody(ctx, b)
+ if err != nil {
+ return false, fmt.Errorf("failed to parse response body: %w", err)
+ }
+
+ fmt.Println("new access token:", accessToken)
+ fmt.Println("new refresh token:", refreshToken)
+ fmt.Println("new token expiration:", expiration)
+
+ // look up user's shop id
+
+ shopID, err := p.getNewUserShopID(ctx, accessToken, userID)
+ if err != nil {
+ return false, fmt.Errorf("failed to complete sign on due to failing to look up the user's shop id: %w", err)
+ }
+
+ // save all to database
+ const ninetyDays = 90 * 24 * time.Hour
+
+ if err := p.saveNewEtsyUser(
+ ctx,
+ EtsyUser{
+ AcctID: acctID,
+ UserID: userID,
+ ShopID: shopID,
+ },
+ etsyAccessTokens{
+ access: tokenAndExpiration{
+ token: accessToken,
+ expiration: expiration,
+ },
+ refresh: tokenAndExpiration{
+ token: refreshToken,
+ expiration: time.Now().Add(ninetyDays),
+ },
+ },
+ ); err != nil {
+ return false, fmt.Errorf("failed to save new user and access token: %w", err)
+ }
+
+ return true, nil
+}
+
+func (p *Platform) parseAccessCodeResponseBody(ctx context.Context, body []byte) (
+ accessToken string,
+ refreshToken string,
+ expiration time.Time,
+ userID int64, // stored in the access token & refresh token
+ err error,
+) {
+ // parse and validate response body
+
+ var (
+ tokenType string
+ expirationInSeconds int
+ )
+
+ if err = json.Unmarshal(body, &struct {
+ Access_Token *string
+ Token_Type *string
+ Expires_In *int
+ Refresh_Token *string
+ }{
+ Access_Token: &accessToken,
+ Token_Type: &tokenType,
+ Expires_In: &expirationInSeconds,
+ Refresh_Token: &refreshToken,
+ }); err != nil {
+ err = fmt.Errorf("failed to decode request body as json: %w", err)
+ return
+ }
+
+ if v := accessToken; v == "" {
+ err = fmt.Errorf("no access token specified in access token response: body = %s", string(body))
+ return
+ } else if accessTokenParts := strings.SplitN(accessToken, ".", 2); len(accessTokenParts) < 2 {
+ err = fmt.Errorf("unexpected access token format: expected a user_id prefix: .: %s: body = %s", accessToken, string(body))
+ return
+ } else if i, ierr := strconv.ParseInt(accessTokenParts[0], 10, 64); ierr != nil {
+ err = fmt.Errorf("unexpected user_id in access token: should be an integer: %s: %w", accessTokenParts[0], ierr)
+ return
+ } else if i <= 0 {
+ err = fmt.Errorf("unexpected user_id in access token: should be a positive integer: %d: %w", i, err)
+ } else {
+ userID = i
+ }
+
+ if tt := tokenType; tt == "" {
+ err = fmt.Errorf("no token type specified in access token response: body = %s", string(body))
+ return
+ } else if tt != "Bearer" {
+ err = fmt.Errorf("unexpected token type specified in access token response: %s: body = %s", tt, string(body))
+ return
+ }
+
+ if v := expirationInSeconds; v == 0 {
+ err = fmt.Errorf("no expiration specified in access token response: body = %s", string(body))
+ return
+ } else if v < 0 {
+ err = fmt.Errorf("unexpected expiration specified in access token response: %d: body = %s", v, string(body))
+ return
+ } else {
+ expiration = time.Now().UTC().Add(time.Duration(expirationInSeconds) * time.Second)
+ }
+
+ if v := refreshToken; v == "" {
+ err = fmt.Errorf("no refresh token specified in access token response: body = %s", string(body))
+ return
+ }
+
+ return
+}
+
+func (p *Platform) getNewUserShopID(ctx context.Context, accessToken string, userID int64) (int64, error) {
+ cli, err := newFixedAccessTokenClient(p.apiKeystring, accessToken)
+ if err != nil {
+ return 0, fmt.Errorf("failed to initialize openapi client: %w", err)
+ }
+
+ var res *generated_client.GetShopByOwnerUserIdResponse
+ if res, err = cli.GetShopByOwnerUserIdWithResponse(ctx, userID); err != nil {
+ return 0, fmt.Errorf("failed to obtain shop id due to failure to initialize request to obtain shop id: %w", err)
+ } else if res.JSON400 != nil {
+ return 0, fmt.Errorf("failed to look up shop for user %d due to 400 error: %s", userID, res.JSON400.Error)
+ } else if res.JSON403 != nil {
+ return 0, fmt.Errorf("failed to look up shop for user %d due to 403 error: %s", userID, res.JSON403.Error)
+ } else if res.JSON404 != nil {
+ return 0, fmt.Errorf("failed to look up shop for user %d due to 404 error: %s", userID, res.JSON404.Error)
+ } else if res.JSON500 != nil {
+ return 0, fmt.Errorf("failed to look up shop for user %d due to 500 error: %s", userID, res.JSON500.Error)
+ }
+
+ if res.JSON200.ShopId == nil {
+ return 0, fmt.Errorf("shop for user %d has no shop_id set: body = %s", userID, res.Body)
+ }
+
+ return *res.JSON200.ShopId, nil
}
diff --git a/internal/domains/platforms/etsy/store.go b/internal/domains/platforms/etsy/store.go
new file mode 100644
index 0000000..2a684fe
--- /dev/null
+++ b/internal/domains/platforms/etsy/store.go
@@ -0,0 +1,273 @@
+package etsy
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+type (
+ EtsyUser struct {
+ AcctID int64
+ UserID int64
+ ShopID int64
+ }
+
+ etsyAccessTokens struct {
+ access tokenAndExpiration
+ refresh tokenAndExpiration
+ }
+
+ tokenAndExpiration struct {
+ token string
+ expiration time.Time
+ }
+
+ oauth2Request struct {
+ acctID int64
+ state uuid.UUID
+ expiration time.Time
+ pkceCode pkceCode
+ }
+
+ pkceCode struct {
+ verifier [32]byte
+ challenge []byte
+ }
+)
+
+func (p *Platform) GetUserPointerByAccountID(ctx context.Context, acctID int64) (*EtsyUser, error) {
+ rows, err := p.db.Query(
+ ctx,
+ "SELECT user_id, shop_id FROM etsy_users WHERE account_id = @account_id",
+ pgx.NamedArgs{
+ "account_id": acctID,
+ },
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to perform query: %w", err)
+ }
+
+ type Row struct {
+ User_ID int64
+ Shop_ID int64
+ }
+
+ r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to scan row: %w", err)
+ }
+
+ return &EtsyUser{
+ AcctID: acctID,
+ UserID: r.User_ID,
+ ShopID: r.Shop_ID,
+ }, nil
+}
+
+func (p *PlatformWithContext) GetUserPointerByAccountID(acctID int64) (*EtsyUser, error) {
+ return p.p.GetUserPointerByAccountID(p.ctx, acctID)
+}
+
+func (p *Platform) saveNewEtsyUser(
+ ctx context.Context,
+ user EtsyUser,
+ tokens etsyAccessTokens,
+) (err error) {
+ _, err = p.db.Exec(
+ ctx,
+ `
+ WITH new_user (
+ INSERT INTO etsy_users (
+ account_id,
+ user_id,
+ shop_id
+ )
+ VALUES (
+ @account_id,
+ @user_id,
+ @shop_id
+ )
+ RETURNING
+ account_id,
+ user_id,
+ shop_id
+ )
+ INSERT INTO etsy_access_tokens (
+ access_token,
+ refresh_token,
+ access_token_expiration,
+ refresh_token_expiration
+ )
+ VALUE (
+ @access_token,
+ @refresh_token,
+ @access_token_expiration,
+ @refresh_token_expiration
+ )
+ `,
+ pgx.NamedArgs{
+ "account_id": user.AcctID,
+ "user_id": user.UserID,
+ "shop_id": user.ShopID,
+ "access_token": tokens.access.token,
+ "refresh_token": tokens.refresh.token,
+ "access_token_expiration": tokens.access.expiration,
+ "refresh_token_expiration": tokens.refresh.expiration,
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("failed to insert new records: %w", err)
+ }
+
+ return nil
+}
+
+// TODO: clean these up on timer.
+func (p *Platform) createNewOAuthRequest(ctx context.Context, acctID int64) (oauth2Request, error) {
+ req := oauth2Request{
+ acctID: acctID,
+ state: uuid.New(),
+ expiration: time.Now().UTC().Add(10 * time.Minute),
+ pkceCode: newPKCECode(),
+ }
+
+ _, err := p.db.Exec(
+ ctx,
+ `
+ INSERT INTO etsy_oauth_requests (
+ account_id,
+ state,
+ code_verifier,
+ expiration
+ )
+ VALUES (
+ @account_id,
+ @state,
+ @code_verifier,
+ @expiration
+ )
+ `,
+ pgx.NamedArgs{
+ "account_id": req.acctID,
+ "state": req.state[:],
+ "code_verifier": req.pkceCode.verifier[:],
+ "expiration": req.expiration,
+ },
+ )
+ if err != nil {
+ return oauth2Request{}, fmt.Errorf("failed to insert record: %w", err)
+ }
+
+ return req, nil
+}
+
+func (p *Platform) getOauthRequest(ctx context.Context, state uuid.UUID) (req oauth2Request, ok bool, err error) {
+ stateBytes := [16]byte(state)
+
+ rows, err := p.db.Query(
+ ctx,
+ `
+ SELECT
+ account_id,
+ code_verifier,
+ expiration
+ FROM
+ etsy_oauth_requests
+ WHERE
+ state = @state
+ `,
+ pgx.NamedArgs{
+ "state": stateBytes[:],
+ },
+ )
+ if err != nil {
+ return oauth2Request{}, false, fmt.Errorf("failed to perform query: %w", err)
+ }
+
+ type Row struct {
+ Account_ID int64
+ Code_Verifier []byte
+ Expiration time.Time
+ }
+
+ r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return oauth2Request{}, false, nil
+ }
+ }
+
+ if l := len(r.Code_Verifier); l != 32 {
+ return oauth2Request{}, false, fmt.Errorf("code_verifier of unexpected length found: expected = 32, found = %d", l)
+ }
+
+ var verifier [32]byte
+ copy(verifier[:], r.Code_Verifier)
+
+ return oauth2Request{
+ acctID: r.Account_ID,
+ state: state,
+ expiration: r.Expiration.UTC(),
+ pkceCode: pkceCode{
+ verifier: verifier,
+ challenge: generateCodeChallenge(verifier),
+ },
+ }, false, nil
+}
+
+func (p *Platform) InvalidateState(ctx context.Context, state string) error {
+ stateUUID, err := uuid.Parse(state)
+ if err != nil {
+ return nil
+ }
+
+ return p.deleteOauthRequest(ctx, stateUUID)
+}
+
+func (p *Platform) deleteOauthRequest(ctx context.Context, state uuid.UUID) error {
+ _, err := p.db.Exec(
+ ctx,
+ `
+ DELETE FROM etsy_oauth_requests
+ WHERE state = @state
+ `,
+ pgx.NamedArgs{
+ "state": state,
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("failed to execute query: %w", err)
+ }
+
+ return nil
+}
+
+func newPKCECode() pkceCode {
+ var code pkceCode
+ part1 := [16]byte(uuid.New())
+ part2 := [16]byte(uuid.New())
+ copy(code.verifier[0:16], part1[:])
+ copy(code.verifier[16:32], part2[:])
+
+ code.challenge = generateCodeChallenge(code.verifier)
+ return code
+}
+
+func generateCodeChallenge(codeVerifier [32]byte) []byte {
+ return generateSHA256Hash(codeVerifier[:])
+}
+
+func generateSHA256Hash(b []byte) []byte {
+ h := sha256.New()
+ h.Write(b)
+ return h.Sum(nil)
+}
diff --git a/internal/domains/raw_events/events.go b/internal/domains/raw_events/events.go
index 840f382..e88ed1a 100644
--- a/internal/domains/raw_events/events.go
+++ b/internal/domains/raw_events/events.go
@@ -31,30 +31,10 @@ type (
}
)
-func NewStore(ctx context.Context) (*Store, error) {
- pool, err := newPool(ctx)
- if err != nil {
- return nil, err
- }
-
+func NewStore(db *pgxpool.Pool) *Store {
return &Store{
- db: pool,
- }, nil
-}
-
-func newPool(ctx context.Context) (*pgxpool.Pool, error) {
- pool, err := pgxpool.New(ctx, "postgres://app_client:app_password@localhost:5432/inventory_2?sslmode=disable")
- if err != nil {
- return nil, fmt.Errorf("failed to create database client: %w", err)
+ db: db,
}
-
- conn, err := pool.Acquire(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to create a database connection: %w", err)
- }
- conn.Release()
-
- return pool, nil
}
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
diff --git a/internal/site/site.go b/internal/site/site.go
index 82c9d7d..38dbfef 100644
--- a/internal/site/site.go
+++ b/internal/site/site.go
@@ -7,16 +7,61 @@ import (
"net/http"
"net/url"
"path"
+ "path/filepath"
+ "strconv"
"strings"
"github.com/angelbeltran/templater"
+ "ruben/inventory2/internal/domains/accounts"
+ etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
)
-func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
+func NewSiteHandler(
+ dir string,
+ rawEvents *raw_events.Store,
+ accts *accounts.Store,
+ etsy *etsy_platform.Platform,
+) http.Handler {
mux := http.NewServeMux()
+ // api routes
+
+ mux.HandleFunc("POST /accounts", func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ email := r.FormValue("email")
+ if email == "" {
+ http.Error(w, "no email provided", http.StatusBadRequest)
+ return
+ }
+
+ acct, err := accts.CreateAccount(ctx, email)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to create account: %w", err), http.StatusInternalServerError)
+ return
+ }
+
+ http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acct.ID), http.StatusSeeOther)
+ })
+
+ mux.HandleFunc("POST /log-in", func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ email := r.FormValue("email")
+ if email == "" {
+ http.Error(w, "no email provided", http.StatusBadRequest)
+ return
+ }
+
+ acct, err := accts.GetAccountByEmail(ctx, email)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to create account: %w", err), http.StatusInternalServerError)
+ return
+ }
+
+ http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acct.ID), http.StatusSeeOther)
+ })
+
// non-html routes
scfs := http.FileServer(http.Dir(dir + "/scripts"))
@@ -44,6 +89,12 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
// TODO: make "/site" dynamic somehow
return path.Join(append([]string{"/site"}, strParts...)...)
},
+ "splitPath": func(p string) []string {
+ if p == "" {
+ return nil
+ }
+ return strings.Split(strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/"), "/")
+ },
"prettyPrintJSON": func(j json.RawMessage) string {
b, err := json.MarshalIndent(j, " ", "")
@@ -53,6 +104,10 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
return string(b)
},
+ "parseInt64": func(s string) (int64, error) {
+ return strconv.ParseInt(s, 10, 64)
+ },
+
"addInt": func(a, b int) int {
return a + b
},
@@ -66,15 +121,23 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
},
)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ name, pathParams := getPageTemplateNameForURL(r.URL)
b, err := tmplr.ExecutePage(
- getPageTemplateNameForURL(r.URL),
+ name,
"Request",
r,
// add services here
"RawEvents",
- db.WithContext(r.Context()),
+ rawEvents.WithContext(ctx),
"URLCalc",
newURLCalculator(r.URL),
+ "PathParams",
+ pathParams,
+ "Accounts",
+ accts.WithContext(ctx),
+ "Etsy",
+ etsy.WithContext(ctx),
)
if err != nil {
// TODO: handle 'not found' as a 404?
@@ -89,14 +152,69 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
return mux
}
-func getPageTemplateNameForURL(u *url.URL) string {
- filepath := strings.TrimPrefix(strings.TrimSuffix(u.Path, ".html"), "/")
- if filepath == "" {
- // "/" maps to "/home"
- filepath = "home"
+// TODO: clean this up...
+// TODO: somehow tell what the path params are and pass them up.
+// - then consider pushing this functionality into the template library.
+//
+// getPageTemplateNameForURL eliminate any trailing .html or /, and checks for any
+// file with path parameters in the name, eg '{abc}.html.tmpl', prefering exact filename matches.
+func getPageTemplateNameForURL(u *url.URL) (name string, params map[string]string) {
+ fp := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSuffix(u.Path, ".html"), "/"), "/")
+ if fp == "" {
+ // "/" maps to "/index"
+ fp = "index"
}
- return filepath
+ fpParts := strings.Split(fp, "/")
+ res := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(fpParts)
+ for _, combs := range res {
+ const pageBodiesPrefix = "internal/site/templates/page_bodies"
+ pattern := path.Join(pageBodiesPrefix, path.Join(combs...)) + ".html.tmpl"
+
+ matches, _ := filepath.Glob(pattern)
+ if len(matches) == 0 {
+ pattern := path.Join(pageBodiesPrefix, path.Join(combs...), "index") + ".html.tmpl"
+ matches, _ = filepath.Glob(pattern)
+ }
+ if len(matches) > 0 {
+ match := matches[0]
+ name = strings.TrimPrefix(strings.TrimSuffix(match, ".html.tmpl"), pageBodiesPrefix+"/")
+
+ patternParts := strings.Split(name, "/")
+ params = make(map[string]string)
+ for i, pp := range patternParts {
+ if strings.HasPrefix(pp, "{") && strings.HasSuffix(pp, "}") {
+ params[pp[1:len(pp)-1]] = fpParts[i]
+ }
+ }
+
+ return name, params
+ }
+ }
+
+ return fp, nil
+}
+
+func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts []string) [][]string {
+ switch len(filepathParts) {
+ case 0:
+ return nil
+ case 1:
+ return [][]string{
+ []string{filepathParts[0]},
+ []string{"{*}"},
+ }
+ default:
+ tailCombs := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts[1:])
+
+ combs := make([][]string, 2*len(tailCombs))
+ for i, c := range tailCombs {
+ combs[i*2] = append([]string{filepathParts[0]}, c...)
+ combs[i*2+1] = append([]string{"{*}"}, c...)
+ }
+
+ return combs
+ }
}
type URLCalculator struct {
diff --git a/internal/site/templates/component_bodies/nav_bar.html.tmpl b/internal/site/templates/component_bodies/nav_bar.html.tmpl
index fb83940..bb4116b 100644
--- a/internal/site/templates/component_bodies/nav_bar.html.tmpl
+++ b/internal/site/templates/component_bodies/nav_bar.html.tmpl
@@ -7,8 +7,14 @@
-
- Reports
+
+ Sign Up
+
+
+
+
+
+ Log In
diff --git a/internal/site/templates/page_bodies/accounts/{acctID}/index.html.tmpl b/internal/site/templates/page_bodies/accounts/{acctID}/index.html.tmpl
new file mode 100644
index 0000000..9aa5f9d
--- /dev/null
+++ b/internal/site/templates/page_bodies/accounts/{acctID}/index.html.tmpl
@@ -0,0 +1,20 @@
+{{ componentBody "nav_bar" }}
+
+
+Account: {{ .PathParams.acctID }}
+
+{{- $acctID := parseInt64 .PathParams.acctID }}
+
+Email: {{ (.Accounts.GetAccount $acctID).Email }}
+
+{{- $etsyUser := .Etsy.GetUserPointerByAccountID $acctID }}
+{{- if $etsyUser }}
+ Etsy User: {{ $etsyUser.UserID }}; Shop ID: {{ $etsyUser.ShopID }}
+{{- else }}
+
+{{- end }}
+
diff --git a/internal/site/templates/page_bodies/reports.html.tmpl b/internal/site/templates/page_bodies/accounts/{acctID}/reports.html.tmpl
similarity index 100%
rename from internal/site/templates/page_bodies/reports.html.tmpl
rename to internal/site/templates/page_bodies/accounts/{acctID}/reports.html.tmpl
diff --git a/internal/site/templates/page_bodies/home.html.tmpl b/internal/site/templates/page_bodies/index.html.tmpl
similarity index 52%
rename from internal/site/templates/page_bodies/home.html.tmpl
rename to internal/site/templates/page_bodies/index.html.tmpl
index 7cce70e..25ccdee 100644
--- a/internal/site/templates/page_bodies/home.html.tmpl
+++ b/internal/site/templates/page_bodies/index.html.tmpl
@@ -1,3 +1,5 @@
{{ componentBody "nav_bar" }}
Home
+
+
diff --git a/internal/site/templates/page_bodies/log-in.html.tmpl b/internal/site/templates/page_bodies/log-in.html.tmpl
new file mode 100644
index 0000000..ecbbef9
--- /dev/null
+++ b/internal/site/templates/page_bodies/log-in.html.tmpl
@@ -0,0 +1,11 @@
+{{ componentBody "nav_bar" }}
+
+Log In
+
+
diff --git a/internal/site/templates/page_bodies/sign-up.html.tmpl b/internal/site/templates/page_bodies/sign-up.html.tmpl
new file mode 100644
index 0000000..632a03c
--- /dev/null
+++ b/internal/site/templates/page_bodies/sign-up.html.tmpl
@@ -0,0 +1,11 @@
+{{ componentBody "nav_bar" }}
+
+Sign Up
+
+
diff --git a/internal/webhooks/etsy/webhooks.go b/internal/webhooks/etsy/webhooks.go
index 38305c9..31dca72 100644
--- a/internal/webhooks/etsy/webhooks.go
+++ b/internal/webhooks/etsy/webhooks.go
@@ -4,12 +4,22 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "strconv"
"time"
+ "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
)
-func NewWebhookHandler(db *raw_events.Store) http.Handler {
+type Config struct {
+ OAuthRedirectURIWithAcctIDParam string
+}
+
+func NewWebhookHandler(
+ db *raw_events.Store,
+ platform *etsy.Platform,
+ cfg Config,
+) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /test", func(w http.ResponseWriter, r *http.Request) {
@@ -44,5 +54,56 @@ func NewWebhookHandler(db *raw_events.Store) http.Handler {
w.WriteHeader(201)
})
+ mux.HandleFunc("GET "+cfg.OAuthRedirectURIWithAcctIDParam, func(w http.ResponseWriter, r *http.Request) {
+ // get account id for the request
+
+ acctID, err := strconv.ParseInt(r.PathValue("acctID"), 10, 64)
+ if err != nil || acctID <= 0 {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ ctx := r.Context()
+
+ q := r.URL.Query()
+ state := q.Get("state")
+
+ // handle failed, potentially non-consenting, request
+
+ if errCode := q.Get("error"); errCode != "" {
+ errDesc := q.Get("error_description")
+ errURI := q.Get("error_uri")
+
+ fmt.Printf(
+ "error in obtaining an OAuth Token: error=%s, error_desc=%s, error_uri=%s, account_id=%d\n",
+ errCode,
+ errDesc,
+ errURI,
+ acctID,
+ )
+
+ platform.InvalidateState(ctx, state)
+
+ return
+ }
+
+ // validate the state to prevent CSRF attacks
+
+ ok, err := platform.HandleNewAuthCode(ctx, acctID, state, q.Get("code"))
+ if err != nil {
+ w.WriteHeader(http.StatusForbidden)
+ fmt.Println("failed to handle new auth code:", err)
+ return
+ }
+ if !ok {
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+
+ // TODO: response with a redirect to the user's account page (SUCCESS - new sign up or login)!
+
+ http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acctID), http.StatusSeeOther)
+ })
+
return mux
}
diff --git a/internal/webhooks/webhooks.go b/internal/webhooks/webhooks.go
index 0d6f4f9..fc69001 100644
--- a/internal/webhooks/webhooks.go
+++ b/internal/webhooks/webhooks.go
@@ -3,18 +3,23 @@ package webhooks
import (
"net/http"
+ etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/webhooks/etsy"
"ruben/inventory2/internal/webhooks/tiktok"
"ruben/inventory2/internal/webhooks/wix"
)
-func New(db *raw_events.Store) http.Handler {
+type Config struct {
+ Etsy etsy.Config
+}
+
+func New(eventsDB *raw_events.Store, etsyPlatform *etsy_platform.Platform, cfg Config) http.Handler {
wh := http.NewServeMux()
- wh.Handle("/etsy/", http.StripPrefix("/etsy", etsy.NewWebhookHandler(db)))
- wh.Handle("/tiktok/", http.StripPrefix("/tiktok", tiktok.NewWebhookHandler(db)))
- wh.Handle("/wix/", http.StripPrefix("/wix", wix.NewWebhookHandler(db)))
+ wh.Handle("/etsy/", http.StripPrefix("/etsy", etsy.NewWebhookHandler(eventsDB, etsyPlatform, cfg.Etsy)))
+ wh.Handle("/tiktok/", http.StripPrefix("/tiktok", tiktok.NewWebhookHandler(eventsDB)))
+ wh.Handle("/wix/", http.StripPrefix("/wix", wix.NewWebhookHandler(eventsDB)))
return wh
}
diff --git a/main.go b/main.go
index 6b8844e..f30258b 100644
--- a/main.go
+++ b/main.go
@@ -1,5 +1,9 @@
package main
+//go:generate planter postgres://planter:planter@localhost:5432/inventory_2?sslmode=disable -o diagrams/database_schema.uml
+//go:generate plantuml diagrams/*.uml -tsvg
+//go:generate plantuml diagrams/etsy/*.uml -tsvg
+
import (
"context"
"errors"
@@ -10,9 +14,14 @@ import (
"syscall"
"time"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "ruben/inventory2/internal/domains/accounts"
+ etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/site"
"ruben/inventory2/internal/webhooks"
+ etsy_webhooks "ruben/inventory2/internal/webhooks/etsy"
)
const (
@@ -33,14 +42,14 @@ func runApp(ctx context.Context) error {
// connect to the database
- db, err := raw_events.NewStore(ctx)
+ connPool, err := newPool(ctx)
if err != nil {
- return fmt.Errorf("failed to initialize the raw event store: %w", err)
+ return fmt.Errorf("failed to initialize database connection pool: %w", err)
}
// start http server
- srvErrCh := runServer(ctx, db)
+ srvErrCh := runServer(ctx, connPool)
// wait for interrupt signal or unrecoverable failure, then shutdown
@@ -76,10 +85,10 @@ func runApp(ctx context.Context) error {
return errors.Join(errs...)
}
-func runServer(ctx context.Context, db *raw_events.Store) <-chan error {
+func runServer(ctx context.Context, connPool *pgxpool.Pool) <-chan error {
srv := &http.Server{
Addr: ":8082", // local
- Handler: buildHTTPHandler(db),
+ Handler: buildHTTPHandler(connPool),
}
ctx, cancel := context.WithCancel(ctx)
@@ -130,11 +139,26 @@ func runServer(ctx context.Context, db *raw_events.Store) <-chan error {
return errCh
}
-func buildHTTPHandler(db *raw_events.Store) http.Handler {
+func buildHTTPHandler(connPool *pgxpool.Pool) http.Handler {
+ eventsDB := raw_events.NewStore(connPool)
+ etsy := etsy_platform.NewPlatform(
+ func(acctID int64) string {
+ return fmt.Sprintf("/oauth/account/%d/auth_code", acctID)
+ },
+ etsyAPIKeystring,
+ etsyAPISharedSecret,
+ connPool,
+ )
+ accts := accounts.NewStore(connPool)
+
mux := http.NewServeMux()
- mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(db)))
- mux.Handle("/site/", http.StripPrefix("/site", site.NewSiteHandler("./internal/site", db)))
+ mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(eventsDB, etsy, webhooks.Config{
+ Etsy: etsy_webhooks.Config{
+ OAuthRedirectURIWithAcctIDParam: "/oauth/account/{acctID}/auth_code",
+ },
+ })))
+ mux.Handle("/site/", http.StripPrefix("/site", site.NewSiteHandler("./internal/site", eventsDB, accts, etsy)))
mux.Handle("/", http.RedirectHandler("/site", http.StatusPermanentRedirect))
return mux