diff --git a/README.md b/README.md index 5df41c1..ab232f3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ - [ ] Start with just a list of events for a given store (use a static test store) - [ ] ... - [ ] Dark mode +- [ ] don't let a listing be in multiple sync groups - [ ] Next stores on the list (at least hypothetically) - Shopify - WooCommerce diff --git a/database_migrations/000013_sync_group_table_state.down.sql b/database_migrations/000013_sync_group_table_state.down.sql new file mode 100644 index 0000000..25ef3bc --- /dev/null +++ b/database_migrations/000013_sync_group_table_state.down.sql @@ -0,0 +1,4 @@ +DROP TABLE sync_group_listing_drafts; +DROP TABLE sync_group_listings; +DROP TABLE sync_groups; +DROP TYPE platform; diff --git a/database_migrations/000013_sync_group_table_state.up.sql b/database_migrations/000013_sync_group_table_state.up.sql new file mode 100644 index 0000000..26529ac --- /dev/null +++ b/database_migrations/000013_sync_group_table_state.up.sql @@ -0,0 +1,26 @@ +CREATE TYPE platform AS ENUM('Etsy', 'Tiktok', 'Wix'); + +CREATE TABLE sync_groups ( + sync_group_id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts +); + +CREATE TABLE sync_group_listings ( + sync_group_id INTEGER NOT NULL REFERENCES sync_groups, + order_index INTEGER NOT NULL, + platform platform, + shop_id TEXT, + listing_id TEXT, + + PRIMARY KEY (sync_group_id, order_index) +); + +CREATE TABLE sync_group_listing_drafts ( + account_id INTEGER NOT NULL REFERENCES accounts, + order_index INTEGER NOT NULL, + platform platform, + shop_id TEXT, + listing_id TEXT, + + PRIMARY KEY (account_id, order_index) +); diff --git a/diagrams/database_schema.svg b/diagrams/database_schema.svg index 4f8d416..4f05110 100644 --- a/diagrams/database_schema.svg +++ b/diagrams/database_schema.svg @@ -1 +1 @@ -accountsaccount_id:serial [PK]email:textverified:booleanuser_id:text [FK]etsy_access_tokensuser_id:integer [PK][FK]access_token:textrefresh_token:textaccess_token_expiration:timestamp with time zonerefresh_token_expiration:timestamp with time zoneetsy_oauth_requestsstate:bytea [PK]code_verifier:byteaexpiration:timestamp with time zoneaccount_id:integer [FK]etsy_store_eventsstore_id:text [PK][FK]event_timestamp:timestamp with time zone [PK][FK]event_id:text [PK][FK]platform:text [FK]etsy_usersuser_id:integer [PK]account_id:integer [FK]shop_id:integeroauth_login_statesstate:bytea [PK]expiration:timestamp with time zonetarget_uri:textoauth_tokensaccess_token:text [PK]token_type:textrefresh_token:textexpiry:timestamp with time zoneid_token_issuer:textid_token_audience:text[]id_token_subject:text [FK]id_token_expiry:timestamp with time zoneid_token_issued_at:timestamp with time zoneid_token_nonce:textid_token_access_token_hash:textclaims:jsonboauth_usersuser_id:text [PK]raw_store_eventsplatform:text [PK]store_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]raw_payload:jsonbschema_migrationsversion:bigint [PK]dirty:booleantiktok_store_eventsstore_id:text [PK][FK]event_timestamp:timestamp with time zone [PK][FK]event_id:text [PK][FK]platform:text [FK]wix_store_eventsstore_id:text [PK][FK]event_timestamp:timestamp with time zone [PK][FK]event_id:text [PK][FK]platform:text [FK] \ No newline at end of file +accountsaccount_id:serial [PK]email:textverified:booleanuser_id:text [FK]etsy_access_tokensuser_id:integer [PK][FK]access_token:textrefresh_token:textaccess_token_expiration:timestamp with time zonerefresh_token_expiration:timestamp with time zoneetsy_oauth_requestsstate:bytea [PK]code_verifier:byteaexpiration:timestamp with time zoneaccount_id:integer [FK]etsy_store_eventsstore_id:text [PK][FK]event_timestamp:timestamp with time zone [PK][FK]event_id:text [PK][FK]platform:text [FK]etsy_usersuser_id:integer [PK]account_id:integer [FK]shop_id:integeroauth_login_statesstate:bytea [PK]expiration:timestamp with time zonetarget_uri:textoauth_tokensaccess_token:text [PK]token_type:textrefresh_token:textexpiry:timestamp with time zoneid_token_issuer:textid_token_audience:text[]id_token_subject:text [FK]id_token_expiry:timestamp with time zoneid_token_issued_at:timestamp with time zoneid_token_nonce:textid_token_access_token_hash:textclaims:jsonboauth_usersuser_id:text [PK]raw_store_eventsplatform:text [PK]store_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]raw_payload:jsonbschema_migrationsversion:bigint [PK]dirty:booleansync_group_listing_draftsaccount_id:integer [PK][FK]order_index:integer [PK]platform:platformshop_id:textlisting_id:textsync_group_listingssync_group_id:integer [PK][FK]order_index:integer [PK]platform:platformshop_id:textlisting_id:textsync_groupssync_group_id:serial [PK]account_id:integer [FK]tiktok_store_eventsstore_id:text [PK][FK]event_timestamp:timestamp with time zone [PK][FK]event_id:text [PK][FK]platform:text [FK]wix_store_eventsstore_id:text [PK][FK]event_timestamp:timestamp with time zone [PK][FK]event_id:text [PK][FK]platform:text [FK] \ No newline at end of file diff --git a/diagrams/database_schema.uml b/diagrams/database_schema.uml index 881bf41..3b67f05 100644 --- a/diagrams/database_schema.uml +++ b/diagrams/database_schema.uml @@ -85,6 +85,30 @@ entity "**schema_migrations**" { *""dirty"": //boolean // } +entity "**sync_group_listing_drafts**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK]// + -- + ""platform"": //platform // + ""shop_id"": //text // + ""listing_id"": //text // +} + +entity "**sync_group_listings**" { + + ""sync_group_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK]// + -- + ""platform"": //platform // + ""shop_id"": //text // + ""listing_id"": //text // +} + +entity "**sync_groups**" { + + ""sync_group_id"": //serial [PK]// + -- + *""account_id"": //integer [FK]// +} + entity "**tiktok_store_events**" { + ""store_id"": //text [PK][FK]// + ""event_timestamp"": //timestamp with time zone [PK][FK]// @@ -119,6 +143,12 @@ entity "**wix_store_events**" { "**oauth_tokens**" }-- "**oauth_users**" +"**sync_group_listing_drafts**" }-- "**accounts**" + +"**sync_group_listings**" }-- "**sync_groups**" + +"**sync_groups**" }-- "**accounts**" + "**tiktok_store_events**" }-- "**raw_store_events**" "**tiktok_store_events**" }-- "**raw_store_events**" diff --git a/internal/domains/accounts/accounts.go b/internal/domains/accounts/accounts.go index cc81d50..f62ad56 100644 --- a/internal/domains/accounts/accounts.go +++ b/internal/domains/accounts/accounts.go @@ -30,15 +30,11 @@ type ( AccountShop struct { AccountShopIDs - Platform Platform - Name string + Name string } Listing struct { - AccountIDs - Platform Platform - ShopID string - ListingID int64 + AccountShopListingIDs SKU string Name string Description string @@ -53,16 +49,14 @@ type ( AccountShopIDs struct { AccountIDs - ShopID string + Platform Platform + ShopID string } - Platform string -) - -const ( - Etsy Platform = "Etsy" - Tiktok Platform = "Tiktok" - Wix Platform = "Wix" + AccountShopListingIDs struct { + AccountShopIDs + ListingID string + } ) func NewStore(db *pgxpool.Pool) *Store { @@ -300,12 +294,15 @@ func (db *Store) GetShops(ctx context.Context, acctID int64) ([]AccountShop, err return shops, nil } -func (db *Store) GetListingsForShop(ctx context.Context, acctID int64, shopID string) ([]Listing, error) { +func (db *Store) GetListingsForShop(ctx context.Context, acctID int64, platform Platform, shopID string) ([]Listing, error) { var vs []Listing for _, v := range devListings { if v.AccountID != acctID { continue } + if v.Platform != platform { + continue + } if v.ShopID != shopID { continue } diff --git a/internal/domains/accounts/dev.go b/internal/domains/accounts/dev.go index 8c1f7e2..a77f155 100644 --- a/internal/domains/accounts/dev.go +++ b/internal/domains/accounts/dev.go @@ -7,233 +7,281 @@ var ( AccountIDs: AccountIDs{ AccountID: 6, }, - ShopID: "2", + Platform: Etsy, + ShopID: "2", }, - Platform: Etsy, - Name: "Etsy 1", + Name: "Etsy 1", }, AccountShop{ AccountShopIDs: AccountShopIDs{ AccountIDs: AccountIDs{ AccountID: 6, }, - ShopID: "5", + Platform: Etsy, + ShopID: "5", }, - Platform: Etsy, - Name: "Etsy 2", + Name: "Etsy 2", }, AccountShop{ AccountShopIDs: AccountShopIDs{ AccountIDs: AccountIDs{ AccountID: 6, }, - ShopID: "8", + Platform: Etsy, + ShopID: "8", }, - Platform: Etsy, - Name: "Etsy 3", + Name: "Etsy 3", }, AccountShop{ AccountShopIDs: AccountShopIDs{ AccountIDs: AccountIDs{ AccountID: 6, }, - ShopID: "11", + Platform: Tiktok, + ShopID: "11", }, - Platform: Tiktok, - Name: "Tiktok 1", + Name: "Tiktok 1", }, AccountShop{ AccountShopIDs: AccountShopIDs{ AccountIDs: AccountIDs{ AccountID: 6, }, - ShopID: "14", + Platform: Tiktok, + ShopID: "14", }, - Platform: Tiktok, - Name: "Tiktok 2", + Name: "Tiktok 2", }, AccountShop{ AccountShopIDs: AccountShopIDs{ AccountIDs: AccountIDs{ AccountID: 6, }, - ShopID: "17", + Platform: Tiktok, + ShopID: "17", }, - Platform: Tiktok, - Name: "Tiktok 3", + Name: "Tiktok 3", }, AccountShop{ AccountShopIDs: AccountShopIDs{ AccountIDs: AccountIDs{ AccountID: 6, }, - ShopID: "20", + Platform: Wix, + ShopID: "20", }, - Platform: Wix, - Name: "Wix 1", + Name: "Wix 1", }, AccountShop{ AccountShopIDs: AccountShopIDs{ AccountIDs: AccountIDs{ AccountID: 6, }, - ShopID: "23", + Platform: Wix, + ShopID: "23", }, - Platform: Wix, - Name: "Wix 2", + Name: "Wix 2", }, AccountShop{ AccountShopIDs: AccountShopIDs{ AccountIDs: AccountIDs{ AccountID: 6, }, - ShopID: "26", + Platform: Wix, + ShopID: "26", }, - Platform: Wix, - Name: "Wix 3", + Name: "Wix 3", }, } devListings = []Listing{ { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Etsy, + ShopID: "1", + }, + ListingID: "1", }, - Platform: Etsy, - ShopID: "1", - ListingID: 1, SKU: "sku 1", Name: "name 1", Description: "description 1", Count: 51, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Etsy, + ShopID: "1", + }, + ListingID: "2", }, - Platform: Etsy, - ShopID: "1", - ListingID: 2, SKU: "sku 2", Name: "name 2", Description: "description 2", Count: 52, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Etsy, + ShopID: "1", + }, + ListingID: "3", }, - Platform: Etsy, - ShopID: "1", - ListingID: 3, SKU: "sku 3", Name: "name 3", Description: "description 3", Count: 53, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Tiktok, + ShopID: "4", + }, + ListingID: "4", }, - Platform: Tiktok, - ShopID: "4", - ListingID: 4, SKU: "sku 4", Name: "name 4", Description: "description 4", Count: 54, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Tiktok, + ShopID: "4", + }, + ListingID: "5", }, - Platform: Tiktok, - ShopID: "4", - ListingID: 5, SKU: "sku 5", Name: "name 5", Description: "description 5", Count: 55, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Tiktok, + ShopID: "4", + }, + ListingID: "6", }, - Platform: Tiktok, - ShopID: "4", - ListingID: 6, SKU: "sku 6", Name: "name 6", Description: "description 6", Count: 56, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Wix, + ShopID: "7", + }, + ListingID: "7", }, - Platform: Wix, - ShopID: "7", - ListingID: 7, SKU: "sku 7", Name: "name 7", Description: "description 7", Count: 57, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Wix, + ShopID: "7", + }, + ListingID: "8", }, - Platform: Wix, - ShopID: "7", - ListingID: 8, SKU: "sku 8", Name: "name 8", Description: "description 8", Count: 58, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Wix, + ShopID: "7", + }, + ListingID: "9", }, - Platform: Wix, - ShopID: "7", - ListingID: 9, SKU: "sku 9", Name: "name 9", Description: "description 9", Count: 59, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Etsy, + ShopID: "2", + }, + ListingID: "10", }, - Platform: Etsy, - ShopID: "2", - ListingID: 10, SKU: "sku 10", Name: "name 10", Description: "description 10", Count: 60, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Etsy, + ShopID: "2", + }, + ListingID: "11", }, - Platform: Etsy, - ShopID: "2", - ListingID: 11, SKU: "sku 11", Name: "name 11", Description: "description 11", Count: 61, }, { - AccountIDs: AccountIDs{ - AccountID: 6, + AccountShopListingIDs: AccountShopListingIDs{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: 6, + }, + Platform: Etsy, + ShopID: "2", + }, + ListingID: "12", }, - Platform: Etsy, - ShopID: "2", - ListingID: 12, SKU: "sku 12", Name: "name 12", Description: "description 12", diff --git a/internal/domains/accounts/platform.go b/internal/domains/accounts/platform.go new file mode 100644 index 0000000..0c833a9 --- /dev/null +++ b/internal/domains/accounts/platform.go @@ -0,0 +1,54 @@ +package accounts + +import ( + "fmt" + "strings" +) + +type ( + Platform string +) + +const ( + Etsy Platform = "Etsy" + Tiktok Platform = "Tiktok" + Wix Platform = "Wix" +) + +func NewPlatform(s string) (Platform, error) { + switch strings.ToLower(s) { + case strings.ToLower(string(Etsy)): + return Etsy, nil + case strings.ToLower(string(Tiktok)): + return Tiktok, nil + case strings.ToLower(string(Wix)): + return Wix, nil + default: + return "", fmt.Errorf("unrecognized constant: %q", s) + } +} + +// sql.Scanner implementation +func (p *Platform) Scan(src any) error { + var s string + switch v := src.(type) { + case string: + s = v + case byte: + s = string(v) + default: + return fmt.Errorf("unsupported type: %T: %v", src, src) + } + + dst, err := NewPlatform(s) + if err == nil { + *p = dst + } + + return err +} + +// sql/driver.Valuer implementation +func (p Platform) Value() (any, error) { + return string(p), nil +} diff --git a/internal/domains/accounts/store_with_context.go b/internal/domains/accounts/store_with_context.go index 73c8b44..08f5796 100644 --- a/internal/domains/accounts/store_with_context.go +++ b/internal/domains/accounts/store_with_context.go @@ -43,10 +43,38 @@ func (v_ctx *StoreWithContext) GetShops(acctID int64) ([]AccountShop, error) { return v_ctx.Store.GetShops(v_ctx.ctx, acctID) } -func (v_ctx *StoreWithContext) GetListingsForShop(acctID int64, shopID string) ([]Listing, error) { - return v_ctx.Store.GetListingsForShop(v_ctx.ctx, acctID, shopID) +func (v_ctx *StoreWithContext) GetListingsForShop(acctID int64, platform Platform, shopID string) ([]Listing, error) { + return v_ctx.Store.GetListingsForShop(v_ctx.ctx, acctID, platform, shopID) } func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) { return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID) } + +func (v_ctx *StoreWithContext) CreateSyncGroupListingDraft(acctID int64) (int, error) { + return v_ctx.Store.CreateSyncGroupListingDraft(v_ctx.ctx, acctID) +} + +func (v_ctx *StoreWithContext) GetSyncGroupListingDraft(acctID int64, orderIndex int) (SyncGroupListingDraft, error) { + return v_ctx.Store.GetSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex) +} + +func (v_ctx *StoreWithContext) SetShopInSyncGroupListingDraft(acctID int64, orderIndex int, platform Platform, shopID string) error { + return v_ctx.Store.SetShopInSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex, platform, shopID) +} + +func (v_ctx *StoreWithContext) SetListingInSyncGroupListingDraft(acctID int64, orderIndex int, listingID string) error { + return v_ctx.Store.SetListingInSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex, listingID) +} + +func (v_ctx *StoreWithContext) DeleteSyncGroupListingDraft(acctID int64, orderIndex int) (int, error) { + return v_ctx.Store.DeleteSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex) +} + +func (v_ctx *StoreWithContext) GetSyncGroupListingDrafts(acctID int64) ([]SyncGroupListingDraft, error) { + return v_ctx.Store.GetSyncGroupListingDrafts(v_ctx.ctx, acctID) +} + +func (v_ctx *StoreWithContext) SaveNewSyncGroup(acctID int64) (SyncGroup, error) { + return v_ctx.Store.SaveNewSyncGroup(v_ctx.ctx, acctID) +} diff --git a/internal/domains/accounts/sync_groups.go b/internal/domains/accounts/sync_groups.go new file mode 100644 index 0000000..33643e8 --- /dev/null +++ b/internal/domains/accounts/sync_groups.go @@ -0,0 +1,436 @@ +package accounts + +import ( + "context" + "errors" + "fmt" + "ruben/inventory2/internal/consts" + + "github.com/jackc/pgx/v5" +) + +type ( + SyncGroup struct { + SyncGroupIDs + Listings []SyncGroupListing + } + + SyncGroupListing struct { + SyncGroupIDs + AccountShopIDs + ListingID string + } + + SyncGroupIDs struct { + AccountIDs + SyncGroupID int64 + } + + SyncGroupListingDraft struct { + AccountShopIDs + ListingID string + } +) + +func (db *Store) CreateSyncGroupListingDraft(ctx context.Context, acctID int64) (orderIndex int, err error) { + rows, err := db.db.Query( + ctx, + ` + WITH new_order_index AS ( + SELECT + COALESCE(MAX(order_index), -1) + 1 AS order_index + FROM + sync_group_listing_drafts + WHERE + account_id = @account_id + ) + INSERT INTO + sync_group_listing_drafts ( + account_id, + order_index + ) + SELECT + @account_id, + order_index + FROM + new_order_index + RETURNING + order_index + `, + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return 0, fmt.Errorf("failed to perform query: %w", err) + } + + if orderIndex, err = pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]); err != nil { + return 0, fmt.Errorf("failed to scan rows: %w", err) + } + + return orderIndex, nil +} + +func (db *Store) GetSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int) (SyncGroupListingDraft, error) { + rows, err := db.db.Query( + ctx, + ` + SELECT + platform, + shop_id, + listing_id + FROM + sync_group_listing_drafts + WHERE + account_id = @account_id + AND order_index = @order_index + `, + pgx.NamedArgs{ + "account_id": acctID, + "order_index": orderIndex, + }, + ) + if err != nil { + return SyncGroupListingDraft{}, fmt.Errorf("failed to perform query: %w", err) + } + + r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[struct { + Platform *Platform + Shop_id *string + Listing_id *string + }]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return SyncGroupListingDraft{}, consts.ErrNotFound + } + return SyncGroupListingDraft{}, fmt.Errorf("failed to scan rows: %w", err) + } + + return SyncGroupListingDraft{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: acctID, + }, + Platform: deref(r.Platform), + ShopID: deref(r.Shop_id), + }, + ListingID: deref(r.Listing_id), + }, nil +} + +func (db *Store) SetShopInSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, platform Platform, shopID string) error { + tags, err := db.db.Exec( + ctx, + ` + UPDATE + sync_group_listing_drafts + SET + platform = @platform, + shop_id = @shop_id, + listing_id = NULL + WHERE + account_id = @account_id + AND order_index = @order_index + `, + pgx.NamedArgs{ + "account_id": acctID, + "order_index": orderIndex, + "platform": platform, + "shop_id": shopID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + if tags.RowsAffected() != 1 { + return consts.ErrNotFound + } + + return nil +} + +func (db *Store) SetListingInSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, listingID string) error { + tags, err := db.db.Exec( + ctx, + ` + UPDATE + sync_group_listing_drafts + SET + listing_id = @listing_id + WHERE + account_id = @account_id + AND order_index = @order_index + `, + pgx.NamedArgs{ + "account_id": acctID, + "order_index": orderIndex, + "listing_id": listingID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + if tags.RowsAffected() != 1 { + return consts.ErrNotFound + } + + return nil +} + +func (db *Store) DeleteSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int) (numOfRows int, err error) { + rows, err := db.db.Query( + ctx, + ` + WITH updated_drafts AS ( + UPDATE + sync_group_listing_drafts + SET + order_index = (order_index - 1) + WHERE + account_id = @account_id + AND order_index > @order_index + RETURNING + order_index + ), deleted_draft AS ( + DELETE FROM + sync_group_listing_drafts + WHERE + account_id = @account_id + AND order_index = @order_index + RETURNING + true AS found + ) + SELECT + COALESCE(dd.found, false) AS found, + (COALESCE(MAX(ud.order_index), -1) + 1) AS num_rows + FROM + deleted_draft dd + LEFT JOIN + updated_drafts ud + ON true + GROUP BY + ud.order_index, dd.found + LIMIT + 1 + `, + pgx.NamedArgs{ + "account_id": acctID, + "order_index": orderIndex, + }, + ) + if err != nil { + return 0, fmt.Errorf("failed to perform query: %w", err) + } + + type Row struct { + Found bool + Num_rows int + } + + r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, consts.ErrNotFound + } + + return 0, fmt.Errorf("failed to scan rows: %w", err) + } + if !r.Found { + return 0, consts.ErrNotFound + } + + return r.Num_rows, nil +} + +func (db *Store) GetSyncGroupListingDrafts(ctx context.Context, acctID int64) ([]SyncGroupListingDraft, error) { + rows, err := db.db.Query( + ctx, + ` + SELECT + order_index, + platform, + shop_id, + listing_id + FROM + sync_group_listing_drafts + 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 { + Order_index int + Platform *Platform + Shop_id *string + Listing_id *string + } + + rs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[Row]) + if err != nil { + return nil, fmt.Errorf("failed to scan rows: %w", err) + } + + listings := make([]SyncGroupListingDraft, len(rs)) + for _, r := range rs { + listings[r.Order_index] = SyncGroupListingDraft{ + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: acctID, + }, + Platform: deref(r.Platform), + ShopID: deref(r.Shop_id), + }, + ListingID: deref(r.Listing_id), + } + } + + return listings, nil +} + +// TODO: test +func (db *Store) SaveNewSyncGroup(ctx context.Context, acctID int64) (SyncGroup, error) { + txn, err := db.db.Begin(ctx) + if err != nil { + return SyncGroup{}, fmt.Errorf("failed to start transaction: %w", err) + } + defer txn.Rollback(ctx) + + rows, err := txn.Query( + ctx, + "SELECT COUNT(*) FROM sync_group_listing_drafts WHERE account_id = @account_id", + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return SyncGroup{}, fmt.Errorf("failed to perform query: %w", err) + } + + numDrafts, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]) + if err != nil { + return SyncGroup{}, fmt.Errorf("failed to scan rows: %w", err) + } + + if numDrafts < 2 { + return SyncGroup{}, fmt.Errorf("%w: insufficient listings: must be at least 2: %d", consts.ErrConflict, numDrafts) + } + + rows, err = txn.Query( + ctx, + ` + WITH deleted_sync_group_listing_drafts AS ( + DELETE FROM + sync_group_listing_drafts + WHERE + account_id = @account_id + RETURNING + order_index, + platform, + shop_id, + listing_id + ), new_sync_group AS ( + INSERT INTO + sync_groups ( + account_id + ) + VALUES ( + @account_id + ) + RETURNING + sync_group_id + ) + INSERT INTO + sync_groups_listings ( + sync_group_id, + order_index, + platform, + shop_id, + listing_id + ) + SELECT + sync_group_id, + order_index, + platform, + shop_id, + listing_id + FROM + new_sync_group + JOIN + deleted_sync_group_listing_drafts + ON + TRUE + RETURNING + sync_group_id, + order_index, + platform, + shop_id, + listing_id + `, + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return SyncGroup{}, fmt.Errorf("failed to perform query: %w", err) + } + + rs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct { + Sync_group_id int64 + Order_index int + Platform Platform + Shop_id string + Listing_id string + }]) + if err != nil { + return SyncGroup{}, fmt.Errorf("failed to scan rows: %w", err) + } + + if err := txn.Commit(ctx); err != nil { + return SyncGroup{}, fmt.Errorf("failed to commit transaction: %w", err) + } + + listings := make([]SyncGroupListing, len(rs)) + for _, r := range rs { + listings[r.Order_index] = SyncGroupListing{ + SyncGroupIDs: SyncGroupIDs{ + AccountIDs: AccountIDs{ + AccountID: acctID, + }, + SyncGroupID: r.Sync_group_id, + }, + AccountShopIDs: AccountShopIDs{ + AccountIDs: AccountIDs{ + AccountID: acctID, + }, + Platform: r.Platform, + ShopID: r.Shop_id, + }, + ListingID: r.Listing_id, + } + } + + return SyncGroup{ + SyncGroupIDs: SyncGroupIDs{ + AccountIDs: AccountIDs{ + AccountID: acctID, + }, + SyncGroupID: listings[0].SyncGroupID, + }, + Listings: listings, + }, nil +} + +func deref[T any](ptr *T) T { + if ptr == nil { + var zero T + return zero + } + return *ptr +} diff --git a/internal/site/accounts.go b/internal/site/accounts.go index 9fdf512..63122ef 100644 --- a/internal/site/accounts.go +++ b/internal/site/accounts.go @@ -4,8 +4,11 @@ import ( "errors" "fmt" "net/http" + "strconv" "ruben/inventory2/internal/consts" + "ruben/inventory2/internal/domains/accounts" + "ruben/inventory2/internal/site/middleware" "ruben/inventory2/internal/site/response" ) @@ -17,7 +20,7 @@ func (s *Server) createAccount(r *http.Request) (response.Response, error) { return nil, response.BadRequest().Msg("no email provided") } - userID := getIdentity(ctx).User.UserID + userID := middleware.GetIdentity(ctx).User.UserID acct, err := s.accts.CreateAccount(ctx, userID, email) if err != nil { @@ -30,3 +33,125 @@ func (s *Server) createAccount(r *http.Request) (response.Response, error) { return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil } + +// POST /accounts/{acctID}/inventory/sync-groups/draft/listings +func (s *Server) createSyncGroupListingDraft(r *http.Request) (response.Response, error) { + ctx := r.Context() + acctID := middleware.GetIdentity(ctx).Account.AccountID + + orderIndex, err := s.accts.CreateSyncGroupListingDraft(ctx, acctID) + if err != nil { + return nil, response.Errorf("failed to create new listing draft: %w", err) + } + + return response.Redirect( + http.StatusSeeOther, + fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex), + ), nil +} + +// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop +// @platform string +// @shopID string +func (s *Server) setShopInSyncGroupListingDraft(r *http.Request) (response.Response, error) { + ctx := r.Context() + acctID := middleware.GetIdentity(ctx).Account.AccountID + + orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r) + if err != nil { + return nil, err + } + + platformStr := r.FormValue("platform") + platform, err := accounts.NewPlatform(platformStr) + if err != nil { + return nil, response.BadRequest(). + Msgf("unrecognized platform: %s", platformStr) + } + + shopID := r.FormValue("shop-id") + if shopID == "" { + return nil, response.BadRequest(). + Msg("no shop-id provided") + } + + if err := s.accts.SetShopInSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil { + return nil, response.Errorf("failed to set shop: %w", mapConstantErrorsToHTTPErrors(err)) + } + + return response.Redirect( + http.StatusSeeOther, + fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex), + ), nil +} + +// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing +func (s *Server) setListingInSyncGroupListingDraft(r *http.Request) (response.Response, error) { + ctx := r.Context() + acctID := middleware.GetIdentity(ctx).Account.AccountID + + orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r) + if err != nil { + return nil, err + } + + listingID := r.FormValue("listing-id") + if listingID == "" { + return nil, response.BadRequest(). + Msg("no listing-id provided") + } + + if err := s.accts.SetListingInSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil { + return nil, response.Errorf("failed to set listing: %w", mapConstantErrorsToHTTPErrors(err)) + } + + return response.Redirect( + http.StatusSeeOther, + fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex), + ), nil +} + +// DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex} +func (s *Server) deleteSyncGroupListingDraft(r *http.Request) (response.Response, error) { + ctx := r.Context() + acctID := middleware.GetIdentity(ctx).Account.AccountID + + orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r) + if err != nil { + return nil, err + } + + if _, err := s.accts.DeleteSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil { + return nil, response.Errorf("failed to delete listing: %w", mapConstantErrorsToHTTPErrors(err)) + } + + return response.Status(200), nil +} + +// POST /accounts/{acctID}/inventory/sync-groups +func (s *Server) saveNewSyncGroup(r *http.Request) (response.Response, error) { + ctx := r.Context() + acctID := middleware.GetIdentity(ctx).Account.AccountID + + grp, err := s.accts.SaveNewSyncGroup(ctx, acctID) + if err != nil { + return nil, response.Errorf("failed to save new sync group: %w", mapConstantErrorsToHTTPErrors(err)) + } + + return response.Redirect( + http.StatusSeeOther, + // TODO: template not implemented + fmt.Sprintf("/accounts/%d/inventory/sync-groups/%d", acctID, grp.SyncGroupID), + ), nil +} + +func getOrderIndexForSyncGroupListingDraftFromPath(r *http.Request) (int, error) { + orderIndexStr := r.PathValue("orderIndex") + orderIndex, err := strconv.Atoi(orderIndexStr) + if err != nil { + return 0, response.NotFound(). + Msgf("no listing draft found at %s", orderIndexStr) + } + + return orderIndex, nil +} diff --git a/internal/site/cookies/auth.go b/internal/site/cookies/auth.go new file mode 100644 index 0000000..39b58fa --- /dev/null +++ b/internal/site/cookies/auth.go @@ -0,0 +1,17 @@ +package cookies + +import ( + "net/http" + "time" +) + +func AccessToken(tkn string, expiration time.Time) http.Cookie { + return http.Cookie{ + Name: "access_token", + Value: tkn, + Path: "/", + Expires: expiration, + MaxAge: 0, // using Expiration instead + Secure: true, + } +} diff --git a/internal/site/cookie.go b/internal/site/cookies/expired.go similarity index 64% rename from internal/site/cookie.go rename to internal/site/cookies/expired.go index 41cb0c1..1d58c61 100644 --- a/internal/site/cookie.go +++ b/internal/site/cookies/expired.go @@ -1,8 +1,8 @@ -package site +package cookies import "net/http" -func getExpiredCookie(name string) http.Cookie { +func Expired(name string) http.Cookie { return http.Cookie{ Name: name, Path: "/", diff --git a/internal/site/login.go b/internal/site/login.go index 7b76751..0814a52 100644 --- a/internal/site/login.go +++ b/internal/site/login.go @@ -4,15 +4,18 @@ import ( "context" "fmt" "net/http" + "ruben/inventory2/internal/domains/authentication" + "ruben/inventory2/internal/site/cookies" "ruben/inventory2/internal/site/response" - "time" ) +// TODO: use a login/logout server? (prefix: '/auth'?) + // GET /login func (s *Server) loginPage(r *http.Request) (response.Response, error) { ctx := r.Context() - u, err := s.newLoginURL(ctx, "/") + u, err := newLoginURL(ctx, s.auth, "/") if err != nil { return nil, err } @@ -20,31 +23,15 @@ func (s *Server) loginPage(r *http.Request) (response.Response, error) { return response.TemporaryRedirect(u), nil } -func (s *Server) newLoginURL(ctx context.Context, targetURI string) (string, error) { - state, err := s.auth.NewState(ctx, targetURI) +func newLoginURL(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) { + state, err := auth.NewState(ctx, targetURI) if err != nil { return "", fmt.Errorf("failed to generate random state: %w", err) } base64EncodedState := fmt.Sprintf("%x", state[:]) - return s.auth.AuthCodeURL(base64EncodedState), nil -} - -// POST /login -func (s *Server) login(r *http.Request) (response.Response, error) { - ctx := r.Context() - email := r.FormValue("email") - if email == "" { - return nil, response.BadRequest().Msg("no email provided") - } - - acct, err := s.accts.GetAccountByEmail(ctx, email) - if err != nil { - return nil, response.Errorf("failed to create account: %w", err) - } - - return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil + return auth.AuthCodeURL(base64EncodedState), nil } // GET /login/callback @@ -64,18 +51,7 @@ func (s *Server) loginCallback(r *http.Request) (response.Response, error) { // set access_token cookie and redirect to a reasonable place return response.TemporaryRedirect(targetURI). - Cookie(newAccessTokenCookie(accessToken, expiration)), nil -} - -func newAccessTokenCookie(tkn string, expiration time.Time) http.Cookie { - return http.Cookie{ - Name: "access_token", - Value: tkn, - Path: "/", - Expires: expiration, - MaxAge: 0, // using Expiration instead - Secure: true, - } + Cookie(cookies.AccessToken(accessToken, expiration)), nil } // GET /logout @@ -92,5 +68,5 @@ func (s *Server) logoutPage(r *http.Request) (response.Response, error) { } return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()). - Cookie(getExpiredCookie("access_token")), nil + Cookie(cookies.Expired("access_token")), nil } diff --git a/internal/site/auth.go b/internal/site/middleware/auth.go similarity index 53% rename from internal/site/auth.go rename to internal/site/middleware/auth.go index 8b381d5..aab270d 100644 --- a/internal/site/auth.go +++ b/internal/site/middleware/auth.go @@ -1,4 +1,4 @@ -package site +package middleware import ( "bytes" @@ -7,26 +7,49 @@ import ( "fmt" "io" "net/http" - "strconv" - "strings" "time" "ruben/inventory2/internal/consts" "ruben/inventory2/internal/domains/accounts" "ruben/inventory2/internal/domains/authentication" + "ruben/inventory2/internal/site/cookies" "ruben/inventory2/internal/site/response" ) -type identity struct { - AccessToken string - Claims authentication.AccessTokenClaims - User accounts.OAuthUser - Account *accounts.Account +type ( + Auth struct { + auth *authentication.Authenticator + newLoginURL LoginURLProviderFunc + accts *accounts.Store + } + + Identity struct { + AccessToken string + Claims authentication.AccessTokenClaims + User accounts.OAuthUser + Account *accounts.Account + } + + LoginURLProviderFunc = func(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) + + AuthorizationAssertions = response.HandlerFunc +) + +func NewAuth( + auth *authentication.Authenticator, + newLoginURL LoginURLProviderFunc, + accts *accounts.Store, +) *Auth { + return &Auth{ + auth: auth, + newLoginURL: newLoginURL, + accts: accts, + } } -func (s *Server) addIdentity(fn response.HandlerFunc) response.HandlerFunc { +func (a *Auth) AddIdentity(fn response.HandlerFunc) response.HandlerFunc { return func(r *http.Request) (response.Response, error) { - r, err := s.addIdentityToRequest(r) + r, err := a.AddIdentityToRequest(r) if err != nil { return nil, err } @@ -35,7 +58,7 @@ func (s *Server) addIdentity(fn response.HandlerFunc) response.HandlerFunc { } } -func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) { +func (a *Auth) AddIdentityToRequest(r *http.Request) (*http.Request, error) { ck, err := r.Cookie("access_token") if err != nil { return r, nil @@ -45,7 +68,7 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) { accessToken := ck.Value - claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) + claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) if err != nil { if errors.Is(err, consts.ErrNotFound) { return r, nil @@ -58,12 +81,12 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) { return r, nil } - user, acct, err := s.accts.GetUserAndAccountByAccessToken(ctx, accessToken) + user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken) if err != nil { return r, response.Errorf("failed to load user and account defails: %w", err) } - return r.WithContext(setIdentity(ctx, identity{ + return r.WithContext(SetIdentity(ctx, Identity{ AccessToken: accessToken, Claims: claims, User: user, @@ -75,7 +98,7 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) { // - then consider doing the same with authorizationAssertions // auth middleware to verify access_token cookie and set custom claims in the request context -func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions ...authorizationAssertions) response.HandlerFunc { +func (a *Auth) AuthenticateAndAddIdentity(f response.HandlerFunc, assertions ...AuthorizationAssertions) response.HandlerFunc { return func(r *http.Request) (response.Response, error) { ck, err := r.Cookie("access_token") if err != nil { @@ -87,10 +110,10 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions . accessToken := ck.Value - claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) + claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) if err != nil { if errors.Is(err, consts.ErrNotFound) { - u, err := s.newLoginURL(ctx, r.URL.String()) + u, err := a.newLoginURL(ctx, a.auth, r.URL.String()) if err != nil { return nil, response.Errorf("failed to generate login url: %w", err) } @@ -108,22 +131,22 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions . // id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in. const idTokenLifetime = 48 * time.Hour if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) { - accessToken, expiration, err = s.auth.RefreshAccessToken(ctx, accessToken) + accessToken, expiration, err = a.auth.RefreshAccessToken(ctx, accessToken) if err != nil { fmt.Println("failed to refresh access token:", err) return response.TemporaryRedirect("/"). Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))). - Cookie(getExpiredCookie("access_token")), nil + Cookie(cookies.Expired("access_token")), nil } // 'redirect' to same url, to set the new access_token cookie return response.TemporaryRedirect(r.URL.String()). - Cookie(newAccessTokenCookie(accessToken, expiration)), nil + Cookie(cookies.AccessToken(accessToken, expiration)), nil } // add identity info to request context - user, acct, err := s.accts.GetUserAndAccountByAccessToken(ctx, accessToken) + user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken) if err != nil { return nil, response.Errorf("failed to authorize: %w", err) } @@ -134,7 +157,7 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions . } } - return f(r.WithContext(setIdentity(ctx, identity{ + return f(r.WithContext(SetIdentity(ctx, Identity{ AccessToken: accessToken, Claims: claims, User: user, @@ -143,65 +166,15 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions . } } -type authorizationAssertions = response.HandlerFunc - -// TODO: test this! -func authorizeByMatchingAccountID_tmp(acctIDPathPosition int) authorizationAssertions { - return func(r *http.Request) (response.Response, error) { - return nil, authorizeByMatchingAccountID(r, acctIDPathPosition) - } -} - -func (s *Server) getAccessTokenClaims(r *http.Request) (authentication.AccessTokenClaims, bool) { - ck, err := r.Cookie("access_token") - if err != nil { - return authentication.AccessTokenClaims{}, false - } - - ctx := r.Context() - - claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, ck.Value) - if err != nil { - return authentication.AccessTokenClaims{}, false - } - if expiration.Before(time.Now()) { - return authentication.AccessTokenClaims{}, false - } - - return claims, true -} - type identityKey struct{} // stores identity in request context -func setIdentity(ctx context.Context, id identity) context.Context { +func SetIdentity(ctx context.Context, id Identity) context.Context { return context.WithValue(ctx, identityKey{}, id) } // get identity from request context -func getIdentity(ctx context.Context) identity { - id, _ := ctx.Value(identityKey{}).(identity) +func GetIdentity(ctx context.Context) Identity { + id, _ := ctx.Value(identityKey{}).(Identity) return id } - -func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error { - pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/") - if len(pathParts) < acctIDPathPosition { - return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path) - } - - part := pathParts[acctIDPathPosition-1] - acctID, err := strconv.ParseInt(part, 10, 64) - if err != nil { - return response.NotFound(). - Msgf("account does not exist: %s", part) - } - - id := getIdentity(r.Context()) - if id.Account == nil || id.Account.AccountID != acctID { - return response.Unauthorized(). - Msgf("user does not have access to account %d", acctID) - } - - return nil -} diff --git a/internal/site/response/mux.go b/internal/site/response/mux.go new file mode 100644 index 0000000..f704812 --- /dev/null +++ b/internal/site/response/mux.go @@ -0,0 +1,34 @@ +package response + +import "net/http" + +type ( + Mux struct { + Mux *http.ServeMux + middleware []Middleware + } + + Middleware = func(HandlerFunc) HandlerFunc +) + +func NewMux(ms ...Middleware) *Mux { + return &Mux{ + Mux: http.NewServeMux(), + middleware: ms, + } +} + +func (m *Mux) Handle(pattern string, fn HandlerFunc) { + for _, mw := range m.middleware { + prev := fn + fn = mw(func(r *http.Request) (Response, error) { + return prev(r) + }) + } + + m.Mux.Handle(pattern, Handler(fn)) +} + +func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.Mux.ServeHTTP(w, r) +} diff --git a/internal/site/response/write.go b/internal/site/response/write.go index 398203f..be95f2a 100644 --- a/internal/site/response/write.go +++ b/internal/site/response/write.go @@ -48,6 +48,10 @@ func Write(w http.ResponseWriter, r *http.Request, res Response) { } func WriteError(w http.ResponseWriter, err error) { + http.Error(w, err.Error(), GetStatusFromError(err)) +} + +func GetStatusFromError(err error) int { status := http.StatusInternalServerError if e, ok := GetError(err); ok { @@ -56,5 +60,5 @@ func WriteError(w http.ResponseWriter, err error) { } } - http.Error(w, err.Error(), status) + return status } diff --git a/internal/site/server.go b/internal/site/server.go index 4b4966f..2e2b373 100644 --- a/internal/site/server.go +++ b/internal/site/server.go @@ -11,21 +11,24 @@ import ( "github.com/angelbeltran/templater" + "ruben/inventory2/internal/consts" "ruben/inventory2/internal/domains/accounts" "ruben/inventory2/internal/domains/authentication" etsy_platform "ruben/inventory2/internal/domains/platforms/etsy" "ruben/inventory2/internal/domains/raw_events" + "ruben/inventory2/internal/site/middleware" "ruben/inventory2/internal/site/response" ) type Server struct { http.Handler - contentDir string - templater *templater.Templater - rawEvents *raw_events.Store - accts *accounts.Store - etsy *etsy_platform.Platform - auth *authentication.Authenticator + contentDir string + templater *templater.Templater + rawEvents *raw_events.Store + accts *accounts.Store + etsy *etsy_platform.Platform + auth *authentication.Authenticator + authMiddleware *middleware.Auth } func NewServer( @@ -35,14 +38,28 @@ func NewServer( etsy *etsy_platform.Platform, auth *authentication.Authenticator, ) *Server { - mux := http.NewServeMux() + mux := response.NewMux(func(fn response.HandlerFunc) response.HandlerFunc { + return func(r *http.Request) (response.Response, error) { + res, err := fn(r) + if err != nil { + status := response.GetStatusFromError(err) + + // TODO: get better logger + fmt.Printf("[ERROR]: %d: %s; %s\n", status, r.URL, err) + } + + return res, err + } + }) s := &Server{ + Handler: mux, contentDir: contentDir, templater: templater.NewTemplater( contentDir+"/templates", func() template.FuncMap { return template.FuncMap{ + // paths "buildSitePath": func(parts ...any) string { strParts := make([]string, len(parts)) for i, p := range parts { @@ -60,18 +77,30 @@ func NewServer( return strings.Split(strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/"), "/") }, - "prettyPrintJSON": func(j json.RawMessage) string { - b, err := json.MarshalIndent(j, " ", "") - if err != nil { - return string(j) + // params + "addPathParam": func(k string, v any, args map[string]any) (map[string]any, error) { + pathParams, ok := args["PathParams"].(map[string]string) + if !ok { + return nil, fmt.Errorf("PathParams no set are args: %v", args) } - return string(b) + + pathParams[k] = fmt.Sprint(v) + + return args, nil }, + // parsing + "parseInt": func(s string) (int, error) { + return strconv.Atoi(s) + }, "parseInt64": func(s string) (int64, error) { return strconv.ParseInt(s, 10, 64) }, + "parsePlatform": func(s string) (accounts.Platform, error) { + return accounts.NewPlatform(s) + }, + // arithmetic "addInt": func(a, b int) int { return a + b }, @@ -81,6 +110,15 @@ func NewServer( "multInt": func(a, b int) int { return a * b }, + + // json + "prettyPrintJSON": func(j json.RawMessage) string { + b, err := json.MarshalIndent(j, " ", "") + if err != nil { + return string(j) + } + return string(b) + }, } }, ), @@ -88,39 +126,74 @@ func NewServer( accts: accts, etsy: etsy, auth: auth, + authMiddleware: middleware.NewAuth( + auth, + newLoginURL, + accts, + ), } - // api routes + withAuth := func(fn response.HandlerFunc) response.HandlerFunc { + return s.authMiddleware.AuthenticateAndAddIdentity(fn) + } - mux.Handle("GET /login", response.Handler(s.loginPage)) - mux.Handle("GET /login/callback", response.Handler(s.loginCallback)) - mux.Handle("GET /logout", response.Handler(s.logoutPage)) - mux.Handle("POST /accounts", response.Handler(s.authenticateAndAddIdentity(s.createAccount))) + // login - // TODO: eliminate once no longer used. - mux.HandleFunc("POST /login", response.Handler(s.login)) + mux.Handle("GET /login", s.loginPage) + mux.Handle("GET /login/callback", s.loginCallback) + mux.Handle("GET /logout", s.logoutPage) + + // /accounts + + mux.Handle("POST /accounts", withAuth(s.createAccount)) + mux.Handle("POST /accounts/{acctID}/inventory/sync-groups/draft/listings", withAuth(s.createSyncGroupListingDraft)) + mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop", withAuth(s.setShopInSyncGroupListingDraft)) + mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing", withAuth(s.setListingInSyncGroupListingDraft)) + mux.Handle("DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}", withAuth(s.deleteSyncGroupListingDraft)) + mux.Handle("POST /accounts/{acctID}/inventory/sync-groups", withAuth(s.saveNewSyncGroup)) // webpage content + // non-html content: scripts, styles, images, etc + scfs := http.FileServer(http.Dir(contentDir + "/scripts")) - mux.Handle("GET /scripts/", http.StripPrefix("/scripts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux.Mux.Handle("GET /scripts/", http.StripPrefix("/scripts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/javascript") if path.Ext(r.URL.Path) == ".gz" { w.Header().Set("Content-Encoding", "gzip") } scfs.ServeHTTP(w, r) }))) - mux.Handle("GET /styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(contentDir+"/styles")))) + mux.Mux.Handle("GET /styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(contentDir+"/styles")))) - // webpages + // html - // all non-authenticated webpages - mux.HandleFunc("GET /{$}", response.Handler(s.addIdentity(s.serveTemplates))) - - // all authenticated webpages - mux.HandleFunc("GET /", response.Handler(s.authenticateAndAddIdentity(s.serveTemplates))) - - s.Handler = mux + // non-authenticated + mux.Handle("GET /{$}", s.authMiddleware.AddIdentity(s.serveTemplates)) + // authenticated + mux.Handle("GET /", withAuth(s.serveTemplates)) return s } + +func mapConstantErrorsToHTTPErrors(err error) error { + cerr := err + for cerr != nil { + switch cerr { + case consts.ErrNotFound: + return response.NotFound() + case consts.ErrConflict: + return response.Conflict() + } + + uerr, ok := cerr.(interface { + Unwrap() error + }) + if !ok { + return err + } + + cerr = uerr.Unwrap() + } + return err +} diff --git a/internal/site/styles/index.css b/internal/site/styles/index.css index 0316e7b..11b01b9 100644 --- a/internal/site/styles/index.css +++ b/internal/site/styles/index.css @@ -13,10 +13,9 @@ --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); - --text-2xl: 1.5rem; --text-3xl: 1.875rem; - --text-4xl: 2.25rem; --text-5xl: 3rem; + --text-8xl: 6rem; --font-weight-semibold: 600; --font-weight-bold: 700; --radius-lg: var(--radius); @@ -196,6 +195,9 @@ .flex { display: flex; } + .table { + display: table; + } .min-h-full { min-height: 100%; } @@ -235,6 +237,9 @@ .gap-y-\[2em\] { row-gap: 2em; } + .overflow-x-auto { + overflow-x: auto; + } .overflow-x-scroll { overflow-x: scroll; } @@ -314,6 +319,21 @@ } } } + .disabled\:cursor-not-allowed { + &:disabled { + cursor: not-allowed; + } + } + .disabled\:bg-accent-secondary { + &:disabled { + background-color: var(--accent-secondary); + } + } + .disabled\:no-underline { + &:disabled { + text-decoration-line: none; + } + } } @layer base { select { @@ -376,6 +396,7 @@ --muted: var(--base-100); --muted-foreground: var(--base-600); --accent: var(--base-100); + --accent-secondary: var(--base-300); --accent-foreground: var(--base-800); --destructive: oklch(0.577 0.245 27.325); --border: var(--base-200); @@ -417,6 +438,7 @@ --muted: var(--base-800); --muted-foreground: var(--base-300); --accent: var(--base-800); + --accent-secondary: var(--base-600); --accent-foreground: var(--base-200); --destructive: oklch(0.704 0.191 22.216); --border: var(--base-800); @@ -462,6 +484,7 @@ --muted: var(--base-800); --muted-foreground: var(--base-300); --accent: var(--base-800); + --accent-secondary: var(--base-600); --accent-foreground: var(--base-200); --destructive: oklch(0.704 0.191 22.216); --border: var(--base-800); @@ -491,23 +514,23 @@ font-weight: var(--display-weight); } h1 { - font-size: var(--text-5xl); + font-size: var(--text-8xl); } h2 { - font-size: var(--text-4xl); + font-size: var(--text-5xl); } h3 { font-size: var(--text-3xl); } h4 { - font-size: var(--text-2xl); - } - h5 { font-size: var(--text-xl); } - h6 { + h5 { font-size: var(--text-lg); } + h6 { + font-size: var(--text-md); + } ul, ol { list-style: none; } diff --git a/internal/site/styles/typography.css b/internal/site/styles/typography.css index 02f2c9c..35095e5 100644 --- a/internal/site/styles/typography.css +++ b/internal/site/styles/typography.css @@ -60,6 +60,7 @@ --muted: var(--base-100); --muted-foreground: var(--base-600); --accent: var(--base-100); + --accent-secondary: var(--base-300); --accent-foreground: var(--base-800); --destructive: oklch(0.577 0.245 27.325); --border: var(--base-200); @@ -104,6 +105,7 @@ --muted: var(--base-800); --muted-foreground: var(--base-300); --accent: var(--base-800); + --accent-secondary: var(--base-600); --accent-foreground: var(--base-200); --destructive: oklch(0.704 0.191 22.216); --border: var(--base-800); @@ -152,6 +154,7 @@ --muted: var(--base-800); --muted-foreground: var(--base-300); --accent: var(--base-800); + --accent-secondary: var(--base-600); --accent-foreground: var(--base-200); --destructive: oklch(0.704 0.191 22.216); --border: var(--base-800); @@ -236,6 +239,7 @@ --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); + --color-accent-secondary: var(--accent-secondary); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); @@ -268,23 +272,23 @@ } h1 { - font-size: var(--text-5xl); + font-size: var(--text-8xl); } h2 { - font-size: var(--text-4xl); + font-size: var(--text-5xl); } h3 { font-size: var(--text-3xl); } h4 { - font-size: var(--text-2xl); - } - h5 { font-size: var(--text-xl); } - h6 { + h5 { font-size: var(--text-lg); } + h6 { + font-size: var(--text-md); + } ul, ol { list-style: none; diff --git a/internal/site/templates.go b/internal/site/templates.go index 1dada51..343c024 100644 --- a/internal/site/templates.go +++ b/internal/site/templates.go @@ -1,16 +1,16 @@ package site import ( - "bytes" "errors" "fmt" - "io" "net/http" "net/url" "os" "path" "path/filepath" + "ruben/inventory2/internal/site/middleware" "ruben/inventory2/internal/site/response" + "strconv" "strings" ) @@ -65,7 +65,7 @@ func (s *Server) getTemplateNameAndArgs(r *http.Request, templateDir string) (na Account *accounts.Account */ "Identity", - getIdentity(r.Context()), + middleware.GetIdentity(r.Context()), "Auth", newTemplateAuthenticator(r), } @@ -136,37 +136,13 @@ func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(file } func (s *Server) handleTemplateError(err error, templateArgs ...any) (response.Response, error) { - code := getHTTPStatusCode(err) - - if code == http.StatusNotFound || - code == http.StatusForbidden || - code == http.StatusUnauthorized || - isFileNotFoundError(err) { - - b, err := s.templater.ExecutePage("not-found", templateArgs...) - if err != nil { - fmt.Println("failed to render not found page:", err) - return nil, response.NotFound(). - Wrap(err). - Msg("resource not found") - } - - return response.Body(io.NopCloser(bytes.NewBuffer(b))), nil + if isFileNotFoundError(err) { + return nil, response.NotFound(). + Wrap(err). + Msg("resource not found") } - if code == http.StatusConflict { - b, err := s.templater.ExecutePage("conflict", templateArgs...) - if err != nil { - fmt.Println("failed to render conflict page:", err) - return nil, response.Conflict(). - Wrap(err). - Msg("conflict") - } - - return response.Body(io.NopCloser(bytes.NewBuffer(b))), nil - } - - return nil, fmt.Errorf("failed to render page: %w", err) + return nil, err } func isFileNotFoundError(err error) bool { @@ -230,3 +206,25 @@ type templateAuthorizationFunc = func() (string, error) func (a *templateAuthenticator) ByMatchingAccountID(acctIDPathPosition int) (string, error) { return "", authorizeByMatchingAccountID(a.req, acctIDPathPosition) } + +func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error { + pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/") + if len(pathParts) < acctIDPathPosition { + return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path) + } + + part := pathParts[acctIDPathPosition-1] + acctID, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return response.NotFound(). + Msgf("account does not exist: %s", part) + } + + id := middleware.GetIdentity(r.Context()) + if id.Account == nil || id.Account.AccountID != acctID { + return response.Unauthorized(). + Msgf("user does not have access to account %d", acctID) + } + + return nil +} diff --git a/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}.html.tmpl b/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}.html.tmpl new file mode 100644 index 0000000..0219382 --- /dev/null +++ b/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}.html.tmpl @@ -0,0 +1,126 @@ +{{/* TODO: make the save button disabled based on an api call */}} +{{/* .Identity.Account.AccountID, .Accounts */}} + +{{- $dot := or .dot . -}} + +{{- $acctID := $dot.Identity.Account.AccountID }} + +{{- $orderIndex := or $dot.OrderIndex (parseInt $dot.PathParams.orderIndex) -}} +{{- $entry := $dot.Accounts.GetSyncGroupListingDraft $acctID $orderIndex }} +{{- $selectedShopPlatform := $entry.Platform }} +{{- $selectedShopID := $entry.ShopID }} +{{- $selectedListingID := $entry.ListingID }} + + + + + {{- $shops := $dot.Accounts.GetShops $acctID -}} + + + + + {{- $selectedListing := "" }} + {{- if $selectedShopID }} + {{- $listings := $dot.Accounts.GetListingsForShop $acctID $selectedShopPlatform $selectedShopID }} + {{- if $listings }} + + {{- else }} + no listings found + {{- end }} + {{- else }} + - + {{- end }} + + + + {{if $selectedListing}}{{$selectedListing.SKU}}{{else}}-{{end}} + + + + {{if $selectedListing}}{{$selectedListing.Description}}{{else}}-{{end}} + + + + {{ componentBody "button-dev" + "HXDelete" (printf "/accounts/%d/inventory/sync-groups/draft/listings/%d" $acctID $orderIndex) + "HXTarget" "closest tr" + "HXSwap" "outerHTML" + "Text" "Remove" + }} + + diff --git a/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table-v2.html.tmpl b/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table-v2.html.tmpl index c28ffb8..c5306d5 100644 --- a/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table-v2.html.tmpl +++ b/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table-v2.html.tmpl @@ -1,5 +1,3 @@ -{{/* .Identity.Account.AccountID | .PathParams.acctID | .AccountID */}} - {{- $dot := or .dot . }} {{- .Auth.ByMatchingAccountID 2 }} @@ -16,7 +14,7 @@ {{- $stores := $dot.Accounts.GetShops $acctID -}} - +
- - {{/* TODO: list existing row */}} - {{- componentBody "accounts/{acctID}/inventory/sync-table-v2/new-row" "dot" $dot }} - {{/* TODO: save the saved rows */}} + + {{- $listings := $dot.Accounts.GetSyncGroupListingDrafts $acctID }} + {{- range $i, $listing := $listings }} + {{- componentBody "accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}" + "dot" ($dot | addPathParam "orderIndex" $i) + }} + {{- end }} diff --git a/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table-v2/new-row.html.tmpl b/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table-v2/new-row.html.tmpl deleted file mode 100644 index 4890320..0000000 --- a/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table-v2/new-row.html.tmpl +++ /dev/null @@ -1,64 +0,0 @@ -{{/* .Identity.Account.AccountID, .Accounts */}} - -{{- $dot := or .dot . -}} - - -{{- $acctID := $dot.Identity.Account.AccountID }} -{{- $shops := $dot.Accounts.GetShops $acctID -}} - - - - - - - - - diff --git a/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table/new-row.html.tmpl b/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table/new-row.html.tmpl index a0a02bf..9bba328 100644 --- a/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table/new-row.html.tmpl +++ b/internal/site/templates/component_bodies/accounts/{acctID}/inventory/sync-table/new-row.html.tmpl @@ -1,3 +1,4 @@ +{{/* TODO: delete when done with the newer draft */}} {{- $dot := or .dot .}} {{- $dot.Auth.ByMatchingAccountID 2 }} @@ -8,7 +9,6 @@ {{- range $shop := $shops }} - {{- $listings := $dot.Accounts.GetListingsForShop $acctID $shop.ShopID }} in #inventory-table-2 + ` + }} + + {{/* TODO: enable the button when all listings are filled and there are at least two listings */}} + {{/* TODO: save the listings as a sync group on click */}} + {{ componentBody "button-dev" + "ID" "create-sync-group-button" + "HXPost" (printf "/accounts/%d/sync-groups" $acctID) + "Class" "mt-[1em] mb-[1em]" + "Text" "Save Sync Group" + "Disabled" true + "_" ` + on setDisabled(disabled) + if disabled then + add @disabled to me + else + remove @disabled from me + end + ` + }} +
@@ -28,12 +64,11 @@
- + {{ componentBody "button-dev" + "HXGet" (printf "/accounts/%d/inventory/sync-table/new-row" $acctID) + "HXTarget" "#inventory-table > tbody" + "HXSwap" "beforeend" + "Class" "mt-[1em] mb-[1em]" + "Text" "Add Row" + }}
@@ -36,11 +34,36 @@
- - - - - - - - - - - - -
diff --git a/internal/site/templates/component_bodies/accounts/{acctID}/platforms/{platform}/shops/{shopID}/listing-select.html.tmpl b/internal/site/templates/component_bodies/accounts/{acctID}/platforms/{platform}/shops/{shopID}/listing-select.html.tmpl deleted file mode 100644 index 4b86710..0000000 --- a/internal/site/templates/component_bodies/accounts/{acctID}/platforms/{platform}/shops/{shopID}/listing-select.html.tmpl +++ /dev/null @@ -1,29 +0,0 @@ -{{/* .ShopID: default to path param "shopID" */}} - -{{- $shopID := or (and .PathParams .PathParams.shopID) .ShopID }} - -{{ $acctID := .Identity.Account.AccountID }} - - diff --git a/internal/site/templates/component_bodies/button-dev.html.tmpl b/internal/site/templates/component_bodies/button-dev.html.tmpl new file mode 100644 index 0000000..4ef9742 --- /dev/null +++ b/internal/site/templates/component_bodies/button-dev.html.tmpl @@ -0,0 +1,45 @@ +{{/* .HXGet | .HXPost | .HXDelete, .HXTarget, .HXSwap, .HXVals, ._, .Text, .ID, .Class, .Disabled */}} + + diff --git a/internal/site/templates/page_bodies/accounts/{acctID}/inventory.html.tmpl b/internal/site/templates/page_bodies/accounts/{acctID}/inventory.html.tmpl index 3ba3bab..0669699 100644 --- a/internal/site/templates/page_bodies/accounts/{acctID}/inventory.html.tmpl +++ b/internal/site/templates/page_bodies/accounts/{acctID}/inventory.html.tmpl @@ -1,19 +1,55 @@ {{- .Auth.ByMatchingAccountID 2 }} +{{- $acctID := .Identity.Account.AccountID }} +{{- $stores := .Accounts.GetShops $acctID -}} + + {{- define "title" }} Inventory++ {{ end }}

Inventory

-

Sync Stores

- -{{ $acctID := .Identity.Account.AccountID }} -{{ $stores := .Accounts.GetShops $acctID }} +

Synced Listings

- Draft 2 + Create a Sync Group

+

+ (Draft 2) +

{{ componentBody "accounts/{acctID}/inventory/sync-table-v2" "dot" . }} + +
+ {{ componentBody "button-dev" + "HXPost" (printf "/accounts/%d/inventory/sync-groups/draft/listings" $acctID) + "HXTarget" "previous table > tbody" + "HXSwap" "beforeend" + "Class" "mt-[1em] mb-[1em]" + "Text" "Add Listing" + "_" ` + on click + send rowUpdated to the first