account page stubbed: link to etsy sign up

This commit is contained in:
2025-12-29 06:06:13 -07:00
parent 61f9afb39c
commit c9100383e4
35 changed files with 1326 additions and 158 deletions
+34 -3
View File
@@ -3,6 +3,11 @@
# Roadmap # 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 this design document?
- [ ] Complete defining this roadmap checklist - [ ] Complete defining this roadmap checklist
- [ ] Website displaying an audit of store events - [ ] 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
![Application structure](./diagrams/application_structure.svg) ![Application structure](./diagrams/application_structure.svg)
## Website heirarchy ## Website hierarchy
- /site - /site
## Architectural and Software Diagrams # Architectural and Software Diagrams
## Database schema
![Schema](./diagrams/database_schema.svg)
## Events
### Event Sourcing Architecture ### Event Sourcing Architecture
![Event sourcing architecture](./diagrams/event_sourcing.svg) ![Event sourcing architecture](./diagrams/event_sourcing.svg)
@@ -73,6 +84,26 @@ All events (or commands) will be stored in a respective event series, and all da
![Event database tables](./diagrams/event_tables.svg) ![Event database tables](./diagrams/event_tables.svg)
### 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!**
![Event database tables](./diagrams/etsy/obtaining_access_token.svg)
***TODO: create a page that will take billing information and include it in this process***
### Getting a new refresh token
***TODO***
### Models
![Models](./diagrams/etsy/models.svg)
## All Diagrams
All diagrams are stored in the [diagrams](./diagrams) directory All diagrams are stored in the [diagrams](./diagrams) directory
+23
View File
@@ -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
}
@@ -0,0 +1 @@
DROP TABLE accounts CASCADE;
@@ -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);
@@ -0,0 +1,2 @@
DROP TABLE etsy_access_tokens;
DROP TABLE etsy_users;
@@ -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
);
@@ -0,0 +1 @@
DROP TABLE etsy_oauth_requests CASCADE;
@@ -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);
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

+94
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" data-diagram-type="CLASS" height="170px" preserveAspectRatio="none" style="width:79px;height:170px;background:#FFFFFF;" version="1.1" viewBox="0 0 79 170" width="79px" zoomAndPan="magnify"><defs/><g><!--class User--><g class="entity" data-entity="User" data-source-line="3" data-uid="ent0002" id="entity_User"><rect fill="#F1F1F1" height="48" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="62.6179" x="8" y="7"/><ellipse cx="23" cy="23" fill="#ADD1B2" rx="11" ry="11" style="stroke:#181818;stroke-width:1;"/><path d="M25.9688,28.6406 Q25.3906,28.9375 24.75,29.0781 Q24.1094,29.2344 23.4063,29.2344 Q20.9063,29.2344 19.5781,27.5938 Q18.2656,25.9375 18.2656,22.8125 Q18.2656,19.6875 19.5781,18.0313 Q20.9063,16.375 23.4063,16.375 Q24.1094,16.375 24.75,16.5313 Q25.4063,16.6875 25.9688,16.9844 L25.9688,19.7031 Q25.3438,19.125 24.75,18.8594 Q24.1563,18.5781 23.5313,18.5781 Q22.1875,18.5781 21.5,19.6563 Q20.8125,20.7188 20.8125,22.8125 Q20.8125,24.9063 21.5,25.9844 Q22.1875,27.0469 23.5313,27.0469 Q24.1563,27.0469 24.75,26.7813 Q25.3438,26.5 25.9688,25.9219 L25.9688,28.6406 Z " fill="#000000"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="30.6179" x="37" y="28.432">User</text><line style="stroke:#181818;stroke-width:0.5;" x1="9" x2="69.6179" y1="39" y2="39"/><line style="stroke:#181818;stroke-width:0.5;" x1="9" x2="69.6179" y1="47" y2="47"/></g><!--class Shop--><g class="entity" data-entity="Shop" data-source-line="5" data-uid="ent0003" id="entity_Shop"><rect fill="#F1F1F1" height="48" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="65.4179" x="7" y="115"/><ellipse cx="22" cy="131" fill="#ADD1B2" rx="11" ry="11" style="stroke:#181818;stroke-width:1;"/><path d="M24.9688,136.6406 Q24.3906,136.9375 23.75,137.0781 Q23.1094,137.2344 22.4063,137.2344 Q19.9063,137.2344 18.5781,135.5938 Q17.2656,133.9375 17.2656,130.8125 Q17.2656,127.6875 18.5781,126.0313 Q19.9063,124.375 22.4063,124.375 Q23.1094,124.375 23.75,124.5313 Q24.4063,124.6875 24.9688,124.9844 L24.9688,127.7031 Q24.3438,127.125 23.75,126.8594 Q23.1563,126.5781 22.5313,126.5781 Q21.1875,126.5781 20.5,127.6563 Q19.8125,128.7188 19.8125,130.8125 Q19.8125,132.9063 20.5,133.9844 Q21.1875,135.0469 22.5313,135.0469 Q23.1563,135.0469 23.75,134.7813 Q24.3438,134.5 24.9688,133.9219 L24.9688,136.6406 Z " fill="#000000"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="33.4179" x="36" y="136.432">Shop</text><line style="stroke:#181818;stroke-width:0.5;" x1="8" x2="71.4179" y1="147" y2="147"/><line style="stroke:#181818;stroke-width:0.5;" x1="8" x2="71.4179" y1="155" y2="155"/></g><!--reverse link User to Shop--><g class="link" data-entity-1="User" data-entity-2="Shop" data-source-line="7" data-uid="lnk4" id="link_User_Shop"><path codeLine="7" d="M39.5,67 C39.5,84.66 39.5,96.94 39.5,114.68" fill="none" id="User-backto-Shop" style="stroke:#181818;stroke-width:1;"/><polygon fill="none" points="39.5,55,35.5,61,39.5,67,43.5,61,39.5,55" style="stroke:#181818;stroke-width:1;"/></g><!--SRC=[Iyv9B2vM22rEBUBYIWQpWpFo2xYuG28A-RgwKAvwCboTOqfAKMfnCL0ChWP56000]--></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+15
View File
@@ -0,0 +1,15 @@
@startuml
class User
class Shop
User o-- Shop
'class UserAddress
'User o-- UserAddress
@enduml
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

+41
View File
@@ -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
+1 -46
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

+11 -2
View File
@@ -8,26 +8,32 @@ require github.com/jackc/pgx/v5 v5.7.6
require ( require (
github.com/angelbeltran/templater v0.1.0 github.com/angelbeltran/templater v0.1.0
github.com/google/uuid v1.5.0
github.com/oapi-codegen/runtime v1.1.2 github.com/oapi-codegen/runtime v1.1.2
) )
require ( 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/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.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/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
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // 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/mailru/easyjson v0.7.7 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // 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/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // 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/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.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 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
)
+12
View File
@@ -1,4 +1,12 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= 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 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= 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= 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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= 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/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 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+8
View File
@@ -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")
)
+125
View File
@@ -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)
}
+47
View File
@@ -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
}
+310 -9
View File
@@ -1,19 +1,320 @@
package etsy 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 ( "github.com/google/uuid"
etsyAPIKeystring = "38ncokqh0jih5jshfk8iv4n5" "github.com/jackc/pgx/v5/pgxpool"
etsyAPISharedSecret = "jaaw0tyizf"
"ruben/inventory2/internal/domains/platforms/etsy/generated_client"
) )
func GetEtsyAPIKeystring() string { //go:generate oapi-codegen -generate types,client -package generated_client -o generated_client/client.go openapi.3.0.2.json
return etsyAPIKeystring
// 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
} }
func GetEtsyAPISharedSecret() string { PlatformWithContext struct {
return etsyAPISharedSecret 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 members 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 requestAnAuthCode() { func (p *Platform) WithContext(ctx context.Context) *PlatformWithContext {
return &PlatformWithContext{
ctx: ctx,
p: p,
}
}
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: <user_id>.<remaindeder>: %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
} }
+273
View File
@@ -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)
}
+2 -22
View File
@@ -31,30 +31,10 @@ type (
} }
) )
func NewStore(ctx context.Context) (*Store, error) { func NewStore(db *pgxpool.Pool) *Store {
pool, err := newPool(ctx)
if err != nil {
return nil, err
}
return &Store{ return &Store{
db: pool, db: db,
}, 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)
}
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 { func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
+127 -9
View File
@@ -7,16 +7,61 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"path/filepath"
"strconv"
"strings" "strings"
"github.com/angelbeltran/templater" "github.com/angelbeltran/templater"
"ruben/inventory2/internal/domains/accounts"
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events" "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() 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 // non-html routes
scfs := http.FileServer(http.Dir(dir + "/scripts")) 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 // TODO: make "/site" dynamic somehow
return path.Join(append([]string{"/site"}, strParts...)...) 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 { "prettyPrintJSON": func(j json.RawMessage) string {
b, err := json.MarshalIndent(j, " ", "") b, err := json.MarshalIndent(j, " ", "")
@@ -53,6 +104,10 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
return string(b) return string(b)
}, },
"parseInt64": func(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
},
"addInt": func(a, b int) int { "addInt": func(a, b int) int {
return a + b 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) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
name, pathParams := getPageTemplateNameForURL(r.URL)
b, err := tmplr.ExecutePage( b, err := tmplr.ExecutePage(
getPageTemplateNameForURL(r.URL), name,
"Request", "Request",
r, r,
// add services here // add services here
"RawEvents", "RawEvents",
db.WithContext(r.Context()), rawEvents.WithContext(ctx),
"URLCalc", "URLCalc",
newURLCalculator(r.URL), newURLCalculator(r.URL),
"PathParams",
pathParams,
"Accounts",
accts.WithContext(ctx),
"Etsy",
etsy.WithContext(ctx),
) )
if err != nil { if err != nil {
// TODO: handle 'not found' as a 404? // TODO: handle 'not found' as a 404?
@@ -89,14 +152,69 @@ func NewSiteHandler(dir string, db *raw_events.Store) http.Handler {
return mux return mux
} }
func getPageTemplateNameForURL(u *url.URL) string { // TODO: clean this up...
filepath := strings.TrimPrefix(strings.TrimSuffix(u.Path, ".html"), "/") // TODO: somehow tell what the path params are and pass them up.
if filepath == "" { // - then consider pushing this functionality into the template library.
// "/" maps to "/home" //
filepath = "home" // 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 { type URLCalculator struct {
@@ -7,8 +7,14 @@
</li> </li>
<li> <li>
<a href="{{ buildSitePath "reports" }}"> <a href="{{ buildSitePath "sign-up" }}">
Reports Sign Up
</a>
</li>
<li>
<a href="{{ buildSitePath "log-in" }}">
Log In
</a> </a>
</li> </li>
</ul> </ul>
@@ -0,0 +1,20 @@
{{ componentBody "nav_bar" }}
<h1>Account: {{ .PathParams.acctID }}</h1>
{{- $acctID := parseInt64 .PathParams.acctID }}
Email: {{ (.Accounts.GetAccount $acctID).Email }}
{{- $etsyUser := .Etsy.GetUserPointerByAccountID $acctID }}
{{- if $etsyUser }}
<h3>Etsy User: {{ $etsyUser.UserID }}; Shop ID: {{ $etsyUser.ShopID }}</h3>
{{- else }}
<h3>
<a href="{{ .Etsy.GenerateConnectionURLForNewAccount $acctID }}">
Link Your Etsy Store!
</a>
</h3>
{{- end }}
<h2><a href="{{$acctID}}/reports">View Reports</a></h2>
@@ -1,3 +1,5 @@
{{ componentBody "nav_bar" }} {{ componentBody "nav_bar" }}
<h1>Home</h1> <h1>Home</h1>
<h2><a href="sign-up">Sign Up!</a></h2>
@@ -0,0 +1,11 @@
{{ componentBody "nav_bar" }}
<h1>Log In</h1>
<form action="log-in" method="post">
<label>
Email:
<input type="text" required name="email" />
</label>
<input type="submit" value="login" />
</form>
@@ -0,0 +1,11 @@
{{ componentBody "nav_bar" }}
<h1>Sign Up</h1>
<form action="accounts" method="post">
<label>
Email:
<input type="text" required name="email" />
</label>
<input type="submit" value="Submit" />
</form>
+62 -1
View File
@@ -4,12 +4,22 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"time" "time"
"ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events" "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 := http.NewServeMux()
mux.HandleFunc("POST /test", func(w http.ResponseWriter, r *http.Request) { 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) 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 return mux
} }
+9 -4
View File
@@ -3,18 +3,23 @@ package webhooks
import ( import (
"net/http" "net/http"
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events" "ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/webhooks/etsy" "ruben/inventory2/internal/webhooks/etsy"
"ruben/inventory2/internal/webhooks/tiktok" "ruben/inventory2/internal/webhooks/tiktok"
"ruben/inventory2/internal/webhooks/wix" "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 := http.NewServeMux()
wh.Handle("/etsy/", http.StripPrefix("/etsy", etsy.NewWebhookHandler(db))) wh.Handle("/etsy/", http.StripPrefix("/etsy", etsy.NewWebhookHandler(eventsDB, etsyPlatform, cfg.Etsy)))
wh.Handle("/tiktok/", http.StripPrefix("/tiktok", tiktok.NewWebhookHandler(db))) wh.Handle("/tiktok/", http.StripPrefix("/tiktok", tiktok.NewWebhookHandler(eventsDB)))
wh.Handle("/wix/", http.StripPrefix("/wix", wix.NewWebhookHandler(db))) wh.Handle("/wix/", http.StripPrefix("/wix", wix.NewWebhookHandler(eventsDB)))
return wh return wh
} }
+32 -8
View File
@@ -1,5 +1,9 @@
package main 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 ( import (
"context" "context"
"errors" "errors"
@@ -10,9 +14,14 @@ import (
"syscall" "syscall"
"time" "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/domains/raw_events"
"ruben/inventory2/internal/site" "ruben/inventory2/internal/site"
"ruben/inventory2/internal/webhooks" "ruben/inventory2/internal/webhooks"
etsy_webhooks "ruben/inventory2/internal/webhooks/etsy"
) )
const ( const (
@@ -33,14 +42,14 @@ func runApp(ctx context.Context) error {
// connect to the database // connect to the database
db, err := raw_events.NewStore(ctx) connPool, err := newPool(ctx)
if err != nil { 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 // start http server
srvErrCh := runServer(ctx, db) srvErrCh := runServer(ctx, connPool)
// wait for interrupt signal or unrecoverable failure, then shutdown // wait for interrupt signal or unrecoverable failure, then shutdown
@@ -76,10 +85,10 @@ func runApp(ctx context.Context) error {
return errors.Join(errs...) 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{ srv := &http.Server{
Addr: ":8082", // local Addr: ":8082", // local
Handler: buildHTTPHandler(db), Handler: buildHTTPHandler(connPool),
} }
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
@@ -130,11 +139,26 @@ func runServer(ctx context.Context, db *raw_events.Store) <-chan error {
return errCh 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 := http.NewServeMux()
mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(db))) mux.Handle("/webhooks/", http.StripPrefix("/webhooks", webhooks.New(eventsDB, etsy, webhooks.Config{
mux.Handle("/site/", http.StripPrefix("/site", site.NewSiteHandler("./internal/site", db))) 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)) mux.Handle("/", http.RedirectHandler("/site", http.StatusPermanentRedirect))
return mux return mux