save "create sync group" table in database
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
- [ ] Start with just a list of events for a given store (use a static test store)
|
- [ ] Start with just a list of events for a given store (use a static test store)
|
||||||
- [ ] ...
|
- [ ] ...
|
||||||
- [ ] Dark mode
|
- [ ] Dark mode
|
||||||
|
- [ ] don't let a listing be in multiple sync groups
|
||||||
- [ ] Next stores on the list (at least hypothetically)
|
- [ ] Next stores on the list (at least hypothetically)
|
||||||
- Shopify
|
- Shopify
|
||||||
- WooCommerce
|
- WooCommerce
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP TABLE sync_group_listing_drafts;
|
||||||
|
DROP TABLE sync_group_listings;
|
||||||
|
DROP TABLE sync_groups;
|
||||||
|
DROP TYPE platform;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 59 KiB |
@@ -85,6 +85,30 @@ entity "**schema_migrations**" {
|
|||||||
*""dirty"": //boolean //
|
*""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**" {
|
entity "**tiktok_store_events**" {
|
||||||
+ ""store_id"": //text [PK][FK]//
|
+ ""store_id"": //text [PK][FK]//
|
||||||
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
|
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
|
||||||
@@ -119,6 +143,12 @@ entity "**wix_store_events**" {
|
|||||||
|
|
||||||
"**oauth_tokens**" }-- "**oauth_users**"
|
"**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**"
|
||||||
|
|
||||||
"**tiktok_store_events**" }-- "**raw_store_events**"
|
"**tiktok_store_events**" }-- "**raw_store_events**"
|
||||||
|
|||||||
@@ -30,15 +30,11 @@ type (
|
|||||||
|
|
||||||
AccountShop struct {
|
AccountShop struct {
|
||||||
AccountShopIDs
|
AccountShopIDs
|
||||||
Platform Platform
|
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
Listing struct {
|
Listing struct {
|
||||||
AccountIDs
|
AccountShopListingIDs
|
||||||
Platform Platform
|
|
||||||
ShopID string
|
|
||||||
ListingID int64
|
|
||||||
SKU string
|
SKU string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
@@ -53,16 +49,14 @@ type (
|
|||||||
|
|
||||||
AccountShopIDs struct {
|
AccountShopIDs struct {
|
||||||
AccountIDs
|
AccountIDs
|
||||||
|
Platform Platform
|
||||||
ShopID string
|
ShopID string
|
||||||
}
|
}
|
||||||
|
|
||||||
Platform string
|
AccountShopListingIDs struct {
|
||||||
)
|
AccountShopIDs
|
||||||
|
ListingID string
|
||||||
const (
|
}
|
||||||
Etsy Platform = "Etsy"
|
|
||||||
Tiktok Platform = "Tiktok"
|
|
||||||
Wix Platform = "Wix"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewStore(db *pgxpool.Pool) *Store {
|
func NewStore(db *pgxpool.Pool) *Store {
|
||||||
@@ -300,12 +294,15 @@ func (db *Store) GetShops(ctx context.Context, acctID int64) ([]AccountShop, err
|
|||||||
return shops, nil
|
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
|
var vs []Listing
|
||||||
for _, v := range devListings {
|
for _, v := range devListings {
|
||||||
if v.AccountID != acctID {
|
if v.AccountID != acctID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if v.Platform != platform {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if v.ShopID != shopID {
|
if v.ShopID != shopID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ var (
|
|||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
|
Platform: Etsy,
|
||||||
ShopID: "2",
|
ShopID: "2",
|
||||||
},
|
},
|
||||||
Platform: Etsy,
|
|
||||||
Name: "Etsy 1",
|
Name: "Etsy 1",
|
||||||
},
|
},
|
||||||
AccountShop{
|
AccountShop{
|
||||||
@@ -17,9 +17,9 @@ var (
|
|||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
|
Platform: Etsy,
|
||||||
ShopID: "5",
|
ShopID: "5",
|
||||||
},
|
},
|
||||||
Platform: Etsy,
|
|
||||||
Name: "Etsy 2",
|
Name: "Etsy 2",
|
||||||
},
|
},
|
||||||
AccountShop{
|
AccountShop{
|
||||||
@@ -27,9 +27,9 @@ var (
|
|||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
|
Platform: Etsy,
|
||||||
ShopID: "8",
|
ShopID: "8",
|
||||||
},
|
},
|
||||||
Platform: Etsy,
|
|
||||||
Name: "Etsy 3",
|
Name: "Etsy 3",
|
||||||
},
|
},
|
||||||
AccountShop{
|
AccountShop{
|
||||||
@@ -37,9 +37,9 @@ var (
|
|||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
|
Platform: Tiktok,
|
||||||
ShopID: "11",
|
ShopID: "11",
|
||||||
},
|
},
|
||||||
Platform: Tiktok,
|
|
||||||
Name: "Tiktok 1",
|
Name: "Tiktok 1",
|
||||||
},
|
},
|
||||||
AccountShop{
|
AccountShop{
|
||||||
@@ -47,9 +47,9 @@ var (
|
|||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
|
Platform: Tiktok,
|
||||||
ShopID: "14",
|
ShopID: "14",
|
||||||
},
|
},
|
||||||
Platform: Tiktok,
|
|
||||||
Name: "Tiktok 2",
|
Name: "Tiktok 2",
|
||||||
},
|
},
|
||||||
AccountShop{
|
AccountShop{
|
||||||
@@ -57,9 +57,9 @@ var (
|
|||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
|
Platform: Tiktok,
|
||||||
ShopID: "17",
|
ShopID: "17",
|
||||||
},
|
},
|
||||||
Platform: Tiktok,
|
|
||||||
Name: "Tiktok 3",
|
Name: "Tiktok 3",
|
||||||
},
|
},
|
||||||
AccountShop{
|
AccountShop{
|
||||||
@@ -67,9 +67,9 @@ var (
|
|||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
|
Platform: Wix,
|
||||||
ShopID: "20",
|
ShopID: "20",
|
||||||
},
|
},
|
||||||
Platform: Wix,
|
|
||||||
Name: "Wix 1",
|
Name: "Wix 1",
|
||||||
},
|
},
|
||||||
AccountShop{
|
AccountShop{
|
||||||
@@ -77,9 +77,9 @@ var (
|
|||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
|
Platform: Wix,
|
||||||
ShopID: "23",
|
ShopID: "23",
|
||||||
},
|
},
|
||||||
Platform: Wix,
|
|
||||||
Name: "Wix 2",
|
Name: "Wix 2",
|
||||||
},
|
},
|
||||||
AccountShop{
|
AccountShop{
|
||||||
@@ -87,153 +87,201 @@ var (
|
|||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
|
Platform: Wix,
|
||||||
ShopID: "26",
|
ShopID: "26",
|
||||||
},
|
},
|
||||||
Platform: Wix,
|
|
||||||
Name: "Wix 3",
|
Name: "Wix 3",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
devListings = []Listing{
|
devListings = []Listing{
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Etsy,
|
Platform: Etsy,
|
||||||
ShopID: "1",
|
ShopID: "1",
|
||||||
ListingID: 1,
|
},
|
||||||
|
ListingID: "1",
|
||||||
|
},
|
||||||
SKU: "sku 1",
|
SKU: "sku 1",
|
||||||
Name: "name 1",
|
Name: "name 1",
|
||||||
Description: "description 1",
|
Description: "description 1",
|
||||||
Count: 51,
|
Count: 51,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Etsy,
|
Platform: Etsy,
|
||||||
ShopID: "1",
|
ShopID: "1",
|
||||||
ListingID: 2,
|
},
|
||||||
|
ListingID: "2",
|
||||||
|
},
|
||||||
SKU: "sku 2",
|
SKU: "sku 2",
|
||||||
Name: "name 2",
|
Name: "name 2",
|
||||||
Description: "description 2",
|
Description: "description 2",
|
||||||
Count: 52,
|
Count: 52,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Etsy,
|
Platform: Etsy,
|
||||||
ShopID: "1",
|
ShopID: "1",
|
||||||
ListingID: 3,
|
},
|
||||||
|
ListingID: "3",
|
||||||
|
},
|
||||||
SKU: "sku 3",
|
SKU: "sku 3",
|
||||||
Name: "name 3",
|
Name: "name 3",
|
||||||
Description: "description 3",
|
Description: "description 3",
|
||||||
Count: 53,
|
Count: 53,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Tiktok,
|
Platform: Tiktok,
|
||||||
ShopID: "4",
|
ShopID: "4",
|
||||||
ListingID: 4,
|
},
|
||||||
|
ListingID: "4",
|
||||||
|
},
|
||||||
SKU: "sku 4",
|
SKU: "sku 4",
|
||||||
Name: "name 4",
|
Name: "name 4",
|
||||||
Description: "description 4",
|
Description: "description 4",
|
||||||
Count: 54,
|
Count: 54,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Tiktok,
|
Platform: Tiktok,
|
||||||
ShopID: "4",
|
ShopID: "4",
|
||||||
ListingID: 5,
|
},
|
||||||
|
ListingID: "5",
|
||||||
|
},
|
||||||
SKU: "sku 5",
|
SKU: "sku 5",
|
||||||
Name: "name 5",
|
Name: "name 5",
|
||||||
Description: "description 5",
|
Description: "description 5",
|
||||||
Count: 55,
|
Count: 55,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Tiktok,
|
Platform: Tiktok,
|
||||||
ShopID: "4",
|
ShopID: "4",
|
||||||
ListingID: 6,
|
},
|
||||||
|
ListingID: "6",
|
||||||
|
},
|
||||||
SKU: "sku 6",
|
SKU: "sku 6",
|
||||||
Name: "name 6",
|
Name: "name 6",
|
||||||
Description: "description 6",
|
Description: "description 6",
|
||||||
Count: 56,
|
Count: 56,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Wix,
|
Platform: Wix,
|
||||||
ShopID: "7",
|
ShopID: "7",
|
||||||
ListingID: 7,
|
},
|
||||||
|
ListingID: "7",
|
||||||
|
},
|
||||||
SKU: "sku 7",
|
SKU: "sku 7",
|
||||||
Name: "name 7",
|
Name: "name 7",
|
||||||
Description: "description 7",
|
Description: "description 7",
|
||||||
Count: 57,
|
Count: 57,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Wix,
|
Platform: Wix,
|
||||||
ShopID: "7",
|
ShopID: "7",
|
||||||
ListingID: 8,
|
},
|
||||||
|
ListingID: "8",
|
||||||
|
},
|
||||||
SKU: "sku 8",
|
SKU: "sku 8",
|
||||||
Name: "name 8",
|
Name: "name 8",
|
||||||
Description: "description 8",
|
Description: "description 8",
|
||||||
Count: 58,
|
Count: 58,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Wix,
|
Platform: Wix,
|
||||||
ShopID: "7",
|
ShopID: "7",
|
||||||
ListingID: 9,
|
},
|
||||||
|
ListingID: "9",
|
||||||
|
},
|
||||||
SKU: "sku 9",
|
SKU: "sku 9",
|
||||||
Name: "name 9",
|
Name: "name 9",
|
||||||
Description: "description 9",
|
Description: "description 9",
|
||||||
Count: 59,
|
Count: 59,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Etsy,
|
Platform: Etsy,
|
||||||
ShopID: "2",
|
ShopID: "2",
|
||||||
ListingID: 10,
|
},
|
||||||
|
ListingID: "10",
|
||||||
|
},
|
||||||
SKU: "sku 10",
|
SKU: "sku 10",
|
||||||
Name: "name 10",
|
Name: "name 10",
|
||||||
Description: "description 10",
|
Description: "description 10",
|
||||||
Count: 60,
|
Count: 60,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Etsy,
|
Platform: Etsy,
|
||||||
ShopID: "2",
|
ShopID: "2",
|
||||||
ListingID: 11,
|
},
|
||||||
|
ListingID: "11",
|
||||||
|
},
|
||||||
SKU: "sku 11",
|
SKU: "sku 11",
|
||||||
Name: "name 11",
|
Name: "name 11",
|
||||||
Description: "description 11",
|
Description: "description 11",
|
||||||
Count: 61,
|
Count: 61,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
AccountShopListingIDs: AccountShopListingIDs{
|
||||||
|
AccountShopIDs: AccountShopIDs{
|
||||||
AccountIDs: AccountIDs{
|
AccountIDs: AccountIDs{
|
||||||
AccountID: 6,
|
AccountID: 6,
|
||||||
},
|
},
|
||||||
Platform: Etsy,
|
Platform: Etsy,
|
||||||
ShopID: "2",
|
ShopID: "2",
|
||||||
ListingID: 12,
|
},
|
||||||
|
ListingID: "12",
|
||||||
|
},
|
||||||
SKU: "sku 12",
|
SKU: "sku 12",
|
||||||
Name: "name 12",
|
Name: "name 12",
|
||||||
Description: "description 12",
|
Description: "description 12",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -43,10 +43,38 @@ func (v_ctx *StoreWithContext) GetShops(acctID int64) ([]AccountShop, error) {
|
|||||||
return v_ctx.Store.GetShops(v_ctx.ctx, acctID)
|
return v_ctx.Store.GetShops(v_ctx.ctx, acctID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v_ctx *StoreWithContext) GetListingsForShop(acctID int64, shopID string) ([]Listing, error) {
|
func (v_ctx *StoreWithContext) GetListingsForShop(acctID int64, platform Platform, shopID string) ([]Listing, error) {
|
||||||
return v_ctx.Store.GetListingsForShop(v_ctx.ctx, acctID, shopID)
|
return v_ctx.Store.GetListingsForShop(v_ctx.ctx, acctID, platform, shopID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
|
func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
|
||||||
return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+126
-1
@@ -4,8 +4,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"ruben/inventory2/internal/consts"
|
"ruben/inventory2/internal/consts"
|
||||||
|
"ruben/inventory2/internal/domains/accounts"
|
||||||
|
"ruben/inventory2/internal/site/middleware"
|
||||||
"ruben/inventory2/internal/site/response"
|
"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")
|
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)
|
acct, err := s.accts.CreateAccount(ctx, userID, email)
|
||||||
if err != nil {
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package site
|
package cookies
|
||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
func getExpiredCookie(name string) http.Cookie {
|
func Expired(name string) http.Cookie {
|
||||||
return http.Cookie{
|
return http.Cookie{
|
||||||
Name: name,
|
Name: name,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
+10
-34
@@ -4,15 +4,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"ruben/inventory2/internal/domains/authentication"
|
||||||
|
"ruben/inventory2/internal/site/cookies"
|
||||||
"ruben/inventory2/internal/site/response"
|
"ruben/inventory2/internal/site/response"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO: use a login/logout server? (prefix: '/auth'?)
|
||||||
|
|
||||||
// GET /login
|
// GET /login
|
||||||
func (s *Server) loginPage(r *http.Request) (response.Response, error) {
|
func (s *Server) loginPage(r *http.Request) (response.Response, error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
u, err := s.newLoginURL(ctx, "/")
|
u, err := newLoginURL(ctx, s.auth, "/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -20,31 +23,15 @@ func (s *Server) loginPage(r *http.Request) (response.Response, error) {
|
|||||||
return response.TemporaryRedirect(u), nil
|
return response.TemporaryRedirect(u), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) newLoginURL(ctx context.Context, targetURI string) (string, error) {
|
func newLoginURL(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) {
|
||||||
state, err := s.auth.NewState(ctx, targetURI)
|
state, err := auth.NewState(ctx, targetURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to generate random state: %w", err)
|
return "", fmt.Errorf("failed to generate random state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base64EncodedState := fmt.Sprintf("%x", state[:])
|
base64EncodedState := fmt.Sprintf("%x", state[:])
|
||||||
|
|
||||||
return s.auth.AuthCodeURL(base64EncodedState), nil
|
return 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /login/callback
|
// 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
|
// set access_token cookie and redirect to a reasonable place
|
||||||
|
|
||||||
return response.TemporaryRedirect(targetURI).
|
return response.TemporaryRedirect(targetURI).
|
||||||
Cookie(newAccessTokenCookie(accessToken, expiration)), nil
|
Cookie(cookies.AccessToken(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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /logout
|
// GET /logout
|
||||||
@@ -92,5 +68,5 @@ func (s *Server) logoutPage(r *http.Request) (response.Response, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
|
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
|
||||||
Cookie(getExpiredCookie("access_token")), nil
|
Cookie(cookies.Expired("access_token")), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package site
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -7,26 +7,49 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ruben/inventory2/internal/consts"
|
"ruben/inventory2/internal/consts"
|
||||||
"ruben/inventory2/internal/domains/accounts"
|
"ruben/inventory2/internal/domains/accounts"
|
||||||
"ruben/inventory2/internal/domains/authentication"
|
"ruben/inventory2/internal/domains/authentication"
|
||||||
|
"ruben/inventory2/internal/site/cookies"
|
||||||
"ruben/inventory2/internal/site/response"
|
"ruben/inventory2/internal/site/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
type identity struct {
|
type (
|
||||||
|
Auth struct {
|
||||||
|
auth *authentication.Authenticator
|
||||||
|
newLoginURL LoginURLProviderFunc
|
||||||
|
accts *accounts.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
Identity struct {
|
||||||
AccessToken string
|
AccessToken string
|
||||||
Claims authentication.AccessTokenClaims
|
Claims authentication.AccessTokenClaims
|
||||||
User accounts.OAuthUser
|
User accounts.OAuthUser
|
||||||
Account *accounts.Account
|
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) {
|
return func(r *http.Request) (response.Response, error) {
|
||||||
r, err := s.addIdentityToRequest(r)
|
r, err := a.AddIdentityToRequest(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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")
|
ck, err := r.Cookie("access_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r, nil
|
return r, nil
|
||||||
@@ -45,7 +68,7 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
|
|||||||
|
|
||||||
accessToken := ck.Value
|
accessToken := ck.Value
|
||||||
|
|
||||||
claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, consts.ErrNotFound) {
|
if errors.Is(err, consts.ErrNotFound) {
|
||||||
return r, nil
|
return r, nil
|
||||||
@@ -58,12 +81,12 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
|
|||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
user, acct, err := s.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r, response.Errorf("failed to load user and account defails: %w", err)
|
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,
|
AccessToken: accessToken,
|
||||||
Claims: claims,
|
Claims: claims,
|
||||||
User: user,
|
User: user,
|
||||||
@@ -75,7 +98,7 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
|
|||||||
// - then consider doing the same with authorizationAssertions
|
// - then consider doing the same with authorizationAssertions
|
||||||
|
|
||||||
// auth middleware to verify access_token cookie and set custom claims in the request context
|
// 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) {
|
return func(r *http.Request) (response.Response, error) {
|
||||||
ck, err := r.Cookie("access_token")
|
ck, err := r.Cookie("access_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,10 +110,10 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions .
|
|||||||
|
|
||||||
accessToken := ck.Value
|
accessToken := ck.Value
|
||||||
|
|
||||||
claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, consts.ErrNotFound) {
|
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 {
|
if err != nil {
|
||||||
return nil, response.Errorf("failed to generate login url: %w", err)
|
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.
|
// 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
|
const idTokenLifetime = 48 * time.Hour
|
||||||
if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) {
|
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 {
|
if err != nil {
|
||||||
fmt.Println("failed to refresh access token:", err)
|
fmt.Println("failed to refresh access token:", err)
|
||||||
return response.TemporaryRedirect("/").
|
return response.TemporaryRedirect("/").
|
||||||
Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))).
|
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
|
// 'redirect' to same url, to set the new access_token cookie
|
||||||
return response.TemporaryRedirect(r.URL.String()).
|
return response.TemporaryRedirect(r.URL.String()).
|
||||||
Cookie(newAccessTokenCookie(accessToken, expiration)), nil
|
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// add identity info to request context
|
// 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 {
|
if err != nil {
|
||||||
return nil, response.Errorf("failed to authorize: %w", err)
|
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,
|
AccessToken: accessToken,
|
||||||
Claims: claims,
|
Claims: claims,
|
||||||
User: user,
|
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{}
|
type identityKey struct{}
|
||||||
|
|
||||||
// stores identity in request context
|
// 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)
|
return context.WithValue(ctx, identityKey{}, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get identity from request context
|
// get identity from request context
|
||||||
func getIdentity(ctx context.Context) identity {
|
func GetIdentity(ctx context.Context) Identity {
|
||||||
id, _ := ctx.Value(identityKey{}).(identity)
|
id, _ := ctx.Value(identityKey{}).(Identity)
|
||||||
return id
|
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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -48,6 +48,10 @@ func Write(w http.ResponseWriter, r *http.Request, res Response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func WriteError(w http.ResponseWriter, err error) {
|
func WriteError(w http.ResponseWriter, err error) {
|
||||||
|
http.Error(w, err.Error(), GetStatusFromError(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetStatusFromError(err error) int {
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
|
|
||||||
if e, ok := GetError(err); ok {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-23
@@ -11,10 +11,12 @@ import (
|
|||||||
|
|
||||||
"github.com/angelbeltran/templater"
|
"github.com/angelbeltran/templater"
|
||||||
|
|
||||||
|
"ruben/inventory2/internal/consts"
|
||||||
"ruben/inventory2/internal/domains/accounts"
|
"ruben/inventory2/internal/domains/accounts"
|
||||||
"ruben/inventory2/internal/domains/authentication"
|
"ruben/inventory2/internal/domains/authentication"
|
||||||
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||||
"ruben/inventory2/internal/domains/raw_events"
|
"ruben/inventory2/internal/domains/raw_events"
|
||||||
|
"ruben/inventory2/internal/site/middleware"
|
||||||
"ruben/inventory2/internal/site/response"
|
"ruben/inventory2/internal/site/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ type Server struct {
|
|||||||
accts *accounts.Store
|
accts *accounts.Store
|
||||||
etsy *etsy_platform.Platform
|
etsy *etsy_platform.Platform
|
||||||
auth *authentication.Authenticator
|
auth *authentication.Authenticator
|
||||||
|
authMiddleware *middleware.Auth
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(
|
func NewServer(
|
||||||
@@ -35,14 +38,28 @@ func NewServer(
|
|||||||
etsy *etsy_platform.Platform,
|
etsy *etsy_platform.Platform,
|
||||||
auth *authentication.Authenticator,
|
auth *authentication.Authenticator,
|
||||||
) *Server {
|
) *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{
|
s := &Server{
|
||||||
|
Handler: mux,
|
||||||
contentDir: contentDir,
|
contentDir: contentDir,
|
||||||
templater: templater.NewTemplater(
|
templater: templater.NewTemplater(
|
||||||
contentDir+"/templates",
|
contentDir+"/templates",
|
||||||
func() template.FuncMap {
|
func() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
|
// paths
|
||||||
"buildSitePath": func(parts ...any) string {
|
"buildSitePath": func(parts ...any) string {
|
||||||
strParts := make([]string, len(parts))
|
strParts := make([]string, len(parts))
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
@@ -60,18 +77,30 @@ func NewServer(
|
|||||||
return strings.Split(strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/"), "/")
|
return strings.Split(strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/"), "/")
|
||||||
},
|
},
|
||||||
|
|
||||||
"prettyPrintJSON": func(j json.RawMessage) string {
|
// params
|
||||||
b, err := json.MarshalIndent(j, " ", "")
|
"addPathParam": func(k string, v any, args map[string]any) (map[string]any, error) {
|
||||||
if err != nil {
|
pathParams, ok := args["PathParams"].(map[string]string)
|
||||||
return string(j)
|
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) {
|
"parseInt64": func(s string) (int64, error) {
|
||||||
return strconv.ParseInt(s, 10, 64)
|
return strconv.ParseInt(s, 10, 64)
|
||||||
},
|
},
|
||||||
|
"parsePlatform": func(s string) (accounts.Platform, error) {
|
||||||
|
return accounts.NewPlatform(s)
|
||||||
|
},
|
||||||
|
|
||||||
|
// arithmetic
|
||||||
"addInt": func(a, b int) int {
|
"addInt": func(a, b int) int {
|
||||||
return a + b
|
return a + b
|
||||||
},
|
},
|
||||||
@@ -81,6 +110,15 @@ func NewServer(
|
|||||||
"multInt": func(a, b int) int {
|
"multInt": func(a, b int) int {
|
||||||
return a * b
|
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,
|
accts: accts,
|
||||||
etsy: etsy,
|
etsy: etsy,
|
||||||
auth: auth,
|
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))
|
// login
|
||||||
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)))
|
|
||||||
|
|
||||||
// TODO: eliminate once no longer used.
|
mux.Handle("GET /login", s.loginPage)
|
||||||
mux.HandleFunc("POST /login", response.Handler(s.login))
|
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
|
// webpage content
|
||||||
|
|
||||||
|
// non-html content: scripts, styles, images, etc
|
||||||
|
|
||||||
scfs := http.FileServer(http.Dir(contentDir + "/scripts"))
|
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")
|
w.Header().Set("Content-Type", "text/javascript")
|
||||||
if path.Ext(r.URL.Path) == ".gz" {
|
if path.Ext(r.URL.Path) == ".gz" {
|
||||||
w.Header().Set("Content-Encoding", "gzip")
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
}
|
}
|
||||||
scfs.ServeHTTP(w, r)
|
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
|
// non-authenticated
|
||||||
mux.HandleFunc("GET /{$}", response.Handler(s.addIdentity(s.serveTemplates)))
|
mux.Handle("GET /{$}", s.authMiddleware.AddIdentity(s.serveTemplates))
|
||||||
|
// authenticated
|
||||||
// all authenticated webpages
|
mux.Handle("GET /", withAuth(s.serveTemplates))
|
||||||
mux.HandleFunc("GET /", response.Handler(s.authenticateAndAddIdentity(s.serveTemplates)))
|
|
||||||
|
|
||||||
s.Handler = mux
|
|
||||||
|
|
||||||
return s
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,9 @@
|
|||||||
--text-lg--line-height: calc(1.75 / 1.125);
|
--text-lg--line-height: calc(1.75 / 1.125);
|
||||||
--text-xl: 1.25rem;
|
--text-xl: 1.25rem;
|
||||||
--text-xl--line-height: calc(1.75 / 1.25);
|
--text-xl--line-height: calc(1.75 / 1.25);
|
||||||
--text-2xl: 1.5rem;
|
|
||||||
--text-3xl: 1.875rem;
|
--text-3xl: 1.875rem;
|
||||||
--text-4xl: 2.25rem;
|
|
||||||
--text-5xl: 3rem;
|
--text-5xl: 3rem;
|
||||||
|
--text-8xl: 6rem;
|
||||||
--font-weight-semibold: 600;
|
--font-weight-semibold: 600;
|
||||||
--font-weight-bold: 700;
|
--font-weight-bold: 700;
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -196,6 +195,9 @@
|
|||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
.table {
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
.min-h-full {
|
.min-h-full {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
@@ -235,6 +237,9 @@
|
|||||||
.gap-y-\[2em\] {
|
.gap-y-\[2em\] {
|
||||||
row-gap: 2em;
|
row-gap: 2em;
|
||||||
}
|
}
|
||||||
|
.overflow-x-auto {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
.overflow-x-scroll {
|
.overflow-x-scroll {
|
||||||
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 {
|
@layer base {
|
||||||
select {
|
select {
|
||||||
@@ -376,6 +396,7 @@
|
|||||||
--muted: var(--base-100);
|
--muted: var(--base-100);
|
||||||
--muted-foreground: var(--base-600);
|
--muted-foreground: var(--base-600);
|
||||||
--accent: var(--base-100);
|
--accent: var(--base-100);
|
||||||
|
--accent-secondary: var(--base-300);
|
||||||
--accent-foreground: var(--base-800);
|
--accent-foreground: var(--base-800);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: var(--base-200);
|
--border: var(--base-200);
|
||||||
@@ -417,6 +438,7 @@
|
|||||||
--muted: var(--base-800);
|
--muted: var(--base-800);
|
||||||
--muted-foreground: var(--base-300);
|
--muted-foreground: var(--base-300);
|
||||||
--accent: var(--base-800);
|
--accent: var(--base-800);
|
||||||
|
--accent-secondary: var(--base-600);
|
||||||
--accent-foreground: var(--base-200);
|
--accent-foreground: var(--base-200);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: var(--base-800);
|
--border: var(--base-800);
|
||||||
@@ -462,6 +484,7 @@
|
|||||||
--muted: var(--base-800);
|
--muted: var(--base-800);
|
||||||
--muted-foreground: var(--base-300);
|
--muted-foreground: var(--base-300);
|
||||||
--accent: var(--base-800);
|
--accent: var(--base-800);
|
||||||
|
--accent-secondary: var(--base-600);
|
||||||
--accent-foreground: var(--base-200);
|
--accent-foreground: var(--base-200);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: var(--base-800);
|
--border: var(--base-800);
|
||||||
@@ -491,23 +514,23 @@
|
|||||||
font-weight: var(--display-weight);
|
font-weight: var(--display-weight);
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-5xl);
|
font-size: var(--text-8xl);
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: var(--text-4xl);
|
font-size: var(--text-5xl);
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
}
|
}
|
||||||
h4 {
|
h4 {
|
||||||
font-size: var(--text-2xl);
|
|
||||||
}
|
|
||||||
h5 {
|
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-xl);
|
||||||
}
|
}
|
||||||
h6 {
|
h5 {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
|
h6 {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
ul, ol {
|
ul, ol {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
--muted: var(--base-100);
|
--muted: var(--base-100);
|
||||||
--muted-foreground: var(--base-600);
|
--muted-foreground: var(--base-600);
|
||||||
--accent: var(--base-100);
|
--accent: var(--base-100);
|
||||||
|
--accent-secondary: var(--base-300);
|
||||||
--accent-foreground: var(--base-800);
|
--accent-foreground: var(--base-800);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: var(--base-200);
|
--border: var(--base-200);
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
--muted: var(--base-800);
|
--muted: var(--base-800);
|
||||||
--muted-foreground: var(--base-300);
|
--muted-foreground: var(--base-300);
|
||||||
--accent: var(--base-800);
|
--accent: var(--base-800);
|
||||||
|
--accent-secondary: var(--base-600);
|
||||||
--accent-foreground: var(--base-200);
|
--accent-foreground: var(--base-200);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: var(--base-800);
|
--border: var(--base-800);
|
||||||
@@ -152,6 +154,7 @@
|
|||||||
--muted: var(--base-800);
|
--muted: var(--base-800);
|
||||||
--muted-foreground: var(--base-300);
|
--muted-foreground: var(--base-300);
|
||||||
--accent: var(--base-800);
|
--accent: var(--base-800);
|
||||||
|
--accent-secondary: var(--base-600);
|
||||||
--accent-foreground: var(--base-200);
|
--accent-foreground: var(--base-200);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: var(--base-800);
|
--border: var(--base-800);
|
||||||
@@ -236,6 +239,7 @@
|
|||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent-secondary: var(--accent-secondary);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
@@ -268,23 +272,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-5xl);
|
font-size: var(--text-8xl);
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: var(--text-4xl);
|
font-size: var(--text-5xl);
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
}
|
}
|
||||||
h4 {
|
h4 {
|
||||||
font-size: var(--text-2xl);
|
|
||||||
}
|
|
||||||
h5 {
|
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-xl);
|
||||||
}
|
}
|
||||||
h6 {
|
h5 {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
|
h6 {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
|
|
||||||
ul, ol {
|
ul, ol {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
+27
-29
@@ -1,16 +1,16 @@
|
|||||||
package site
|
package site
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"ruben/inventory2/internal/site/middleware"
|
||||||
"ruben/inventory2/internal/site/response"
|
"ruben/inventory2/internal/site/response"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ func (s *Server) getTemplateNameAndArgs(r *http.Request, templateDir string) (na
|
|||||||
Account *accounts.Account
|
Account *accounts.Account
|
||||||
*/
|
*/
|
||||||
"Identity",
|
"Identity",
|
||||||
getIdentity(r.Context()),
|
middleware.GetIdentity(r.Context()),
|
||||||
"Auth",
|
"Auth",
|
||||||
newTemplateAuthenticator(r),
|
newTemplateAuthenticator(r),
|
||||||
}
|
}
|
||||||
@@ -136,37 +136,13 @@ func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(file
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTemplateError(err error, templateArgs ...any) (response.Response, error) {
|
func (s *Server) handleTemplateError(err error, templateArgs ...any) (response.Response, error) {
|
||||||
code := getHTTPStatusCode(err)
|
if isFileNotFoundError(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().
|
return nil, response.NotFound().
|
||||||
Wrap(err).
|
Wrap(err).
|
||||||
Msg("resource not found")
|
Msg("resource not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.Body(io.NopCloser(bytes.NewBuffer(b))), nil
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isFileNotFoundError(err error) bool {
|
func isFileNotFoundError(err error) bool {
|
||||||
@@ -230,3 +206,25 @@ type templateAuthorizationFunc = func() (string, error)
|
|||||||
func (a *templateAuthenticator) ByMatchingAccountID(acctIDPathPosition int) (string, error) {
|
func (a *templateAuthenticator) ByMatchingAccountID(acctIDPathPosition int) (string, error) {
|
||||||
return "", authorizeByMatchingAccountID(a.req, acctIDPathPosition)
|
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
|
||||||
|
}
|
||||||
|
|||||||
+126
@@ -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 }}
|
||||||
|
|
||||||
|
|
||||||
|
<tr _="
|
||||||
|
init
|
||||||
|
set @data-filled-out to false
|
||||||
|
|
||||||
|
def setFilledOut(val)
|
||||||
|
set @data-filled-out to val
|
||||||
|
send rowUpdated() to closest <tbody/>
|
||||||
|
end
|
||||||
|
|
||||||
|
on setListing(listing)
|
||||||
|
call setFilledOut(true)
|
||||||
|
|
||||||
|
|
||||||
|
on resetListing
|
||||||
|
call setFilledOut(false)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
{{- $shops := $dot.Accounts.GetShops $acctID -}}
|
||||||
|
<select
|
||||||
|
class="min-w-fit cursor-pointer"
|
||||||
|
hx-put="/accounts/{{$acctID}}/inventory/sync-groups/draft/listings/{{$orderIndex}}/shop"
|
||||||
|
hx-vals='js:{
|
||||||
|
platform: event.target.value.replace(/-[^ ]*/, ""),
|
||||||
|
"shop-id": event.target.value.replace(/[^ ]*-/, "")
|
||||||
|
}'
|
||||||
|
hx-target="closest tr"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
_="
|
||||||
|
on change
|
||||||
|
send resetListing() to closest <tr/>
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option disabled {{- if not $selectedShopID }}selected{{- end }}>- Shops -</option>
|
||||||
|
{{- range $shop := $shops }}
|
||||||
|
{{- $isSelectedShop := and
|
||||||
|
(eq $selectedShopPlatform $shop.Platform)
|
||||||
|
(eq $selectedShopID $shop.ShopID)
|
||||||
|
}}
|
||||||
|
<option
|
||||||
|
value="{{$shop.Platform}}-{{$shop.ShopID}}"
|
||||||
|
{{- if $isSelectedShop }}selected{{- end }}
|
||||||
|
>
|
||||||
|
{{ $shop.Name }} - {{$shop.Platform}} - {{$shop.ShopID}}
|
||||||
|
</option>
|
||||||
|
{{- end }}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{{- $selectedListing := "" }}
|
||||||
|
{{- if $selectedShopID }}
|
||||||
|
{{- $listings := $dot.Accounts.GetListingsForShop $acctID $selectedShopPlatform $selectedShopID }}
|
||||||
|
{{- if $listings }}
|
||||||
|
<select
|
||||||
|
class="min-w-fit cursor-pointer"
|
||||||
|
|
||||||
|
hx-put="/accounts/{{$acctID}}/inventory/sync-groups/draft/listings/{{$orderIndex}}/listing"
|
||||||
|
hx-vals='js:{
|
||||||
|
"listing-id": event.target.value,
|
||||||
|
}'
|
||||||
|
hx-target="closest tr"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
|
||||||
|
_="
|
||||||
|
on input
|
||||||
|
set dataset to (my selectedOptions)[0].dataset
|
||||||
|
send setListing(listing: dataset) to the closest <tr/>
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option disabled {{if not $selectedListingID}}selected{{end}}>- Listings -</option>
|
||||||
|
{{- range $i, $listing := $listings }}
|
||||||
|
{{- if eq $selectedListingID $listing.ListingID }}
|
||||||
|
{{ $selectedListing = $listing }}
|
||||||
|
{{- end }}
|
||||||
|
<option
|
||||||
|
value="{{$listing.ListingID}}"
|
||||||
|
{{if eq $selectedListingID $listing.ListingID}}selected{{end}}
|
||||||
|
data-name="{{ $listing.Name }}"
|
||||||
|
data-sku="{{ $listing.SKU }}"
|
||||||
|
data-description="{{ $listing.Description }}"
|
||||||
|
data-count="{{ $listing.Count }}"
|
||||||
|
>
|
||||||
|
{{ $listing.Name }}
|
||||||
|
</option>
|
||||||
|
{{- end }}
|
||||||
|
</select>
|
||||||
|
{{- else }}
|
||||||
|
no listings found
|
||||||
|
{{- end }}
|
||||||
|
{{- else }}
|
||||||
|
-
|
||||||
|
{{- end }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td data-id="sku">
|
||||||
|
{{if $selectedListing}}{{$selectedListing.SKU}}{{else}}-{{end}}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td data-id="description">
|
||||||
|
{{if $selectedListing}}{{$selectedListing.Description}}{{else}}-{{end}}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td data-id="remove">
|
||||||
|
{{ componentBody "button-dev"
|
||||||
|
"HXDelete" (printf "/accounts/%d/inventory/sync-groups/draft/listings/%d" $acctID $orderIndex)
|
||||||
|
"HXTarget" "closest tr"
|
||||||
|
"HXSwap" "outerHTML"
|
||||||
|
"Text" "Remove"
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
+30
-7
@@ -1,5 +1,3 @@
|
|||||||
{{/* .Identity.Account.AccountID | .PathParams.acctID | .AccountID */}}
|
|
||||||
|
|
||||||
{{- $dot := or .dot . }}
|
{{- $dot := or .dot . }}
|
||||||
{{- .Auth.ByMatchingAccountID 2 }}
|
{{- .Auth.ByMatchingAccountID 2 }}
|
||||||
|
|
||||||
@@ -16,7 +14,7 @@
|
|||||||
{{- $stores := $dot.Accounts.GetShops $acctID -}}
|
{{- $stores := $dot.Accounts.GetShops $acctID -}}
|
||||||
|
|
||||||
|
|
||||||
<table id="inventory-table-2" class="max-w-full overflow-x-scroll">
|
<table id="inventory-table-2" class="max-w-full overflow-x-auto">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
@@ -36,11 +34,36 @@
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody
|
||||||
{{/* TODO: list existing row */}}
|
_="
|
||||||
{{- componentBody "accounts/{acctID}/inventory/sync-table-v2/new-row" "dot" $dot }}
|
init
|
||||||
{{/* TODO: save the saved rows */}}
|
set element allRowsFilledOut to false
|
||||||
|
set element numRows to 0
|
||||||
|
|
||||||
|
def checkIfAllRowsAreFilledOut()
|
||||||
|
set rows to my children
|
||||||
|
set element allRowsFilledOut to true
|
||||||
|
set element numRows to rows.length
|
||||||
|
for row in rows
|
||||||
|
set filledOut to row.dataset.filledOut is 'true'
|
||||||
|
if not filledOut then
|
||||||
|
set element allRowsFilledOut to false
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
on rowUpdated
|
||||||
|
checkIfAllRowsAreFilledOut()
|
||||||
|
send setDisabled(disabled: not allRowsFilledOut or numRows < 2) to #create-sync-group-button
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{- $listings := $dot.Accounts.GetSyncGroupListingDrafts $acctID }}
|
||||||
|
{{- range $i, $listing := $listings }}
|
||||||
|
{{- componentBody "accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}"
|
||||||
|
"dot" ($dot | addPathParam "orderIndex" $i)
|
||||||
|
}}
|
||||||
|
{{- end }}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
{{/* .Identity.Account.AccountID, .Accounts */}}
|
|
||||||
|
|
||||||
{{- $dot := or .dot . -}}
|
|
||||||
|
|
||||||
|
|
||||||
{{- $acctID := $dot.Identity.Account.AccountID }}
|
|
||||||
{{- $shops := $dot.Accounts.GetShops $acctID -}}
|
|
||||||
|
|
||||||
|
|
||||||
<tr _="
|
|
||||||
on setListing(listing)
|
|
||||||
set the innerHTML of (the last <td[data-id=sku]/> in me) to listing.sku
|
|
||||||
set the innerHTML of (the last <td[data-id=description]/> in me) to listing.description
|
|
||||||
-- TODO: only remove if there isn't already a 'new row'
|
|
||||||
remove @disabled from (the <button/> in last <td[data-id=save]/> in me)
|
|
||||||
on resetListing
|
|
||||||
set the innerHTML of (the last <td[data-id=sku]/> in me) to '-'
|
|
||||||
set the innerHTML of (the last <td[data-id=description]/> in me) to '-'
|
|
||||||
add @disabled to (the <button/> in last <td[data-id=save]/> in me)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
class="min-w-fit cursor-pointer"
|
|
||||||
hx-get="/accounts/{{$acctID}}/platforms/{platform}/shops/{shopID}/listing-select"
|
|
||||||
hx-vals='js:{
|
|
||||||
platform: event.target.value.replace(/-[^ ]*/, ""),
|
|
||||||
shopID: event.target.value.replace(/[^ ]*-/, "")
|
|
||||||
}'
|
|
||||||
hx-target="next td"
|
|
||||||
_="
|
|
||||||
on change
|
|
||||||
log event.target.value
|
|
||||||
send resetListing() to closest <tr/>
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<option disabled selected>- Shops -</option>
|
|
||||||
{{- range $shop := $shops }}
|
|
||||||
<option value="{{$shop.Platform}}-{{$shop.ShopID}}">
|
|
||||||
{{ $shop.Name }} - {{$shop.Platform}} - {{$shop.ShopID}}
|
|
||||||
</option>
|
|
||||||
{{- end }}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
-
|
|
||||||
</td>
|
|
||||||
<td data-id="sku">
|
|
||||||
-
|
|
||||||
</td>
|
|
||||||
<td data-id="description">
|
|
||||||
-
|
|
||||||
</td>
|
|
||||||
<td data-id="save">
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
hx-get="/accounts/{{$acctID}}/inventory/sync-table-v2/new-row"
|
|
||||||
hx-target="closest tr"
|
|
||||||
hx-swap="afterend"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
|
{{/* TODO: delete when done with the newer draft */}}
|
||||||
{{- $dot := or .dot .}}
|
{{- $dot := or .dot .}}
|
||||||
|
|
||||||
{{- $dot.Auth.ByMatchingAccountID 2 }}
|
{{- $dot.Auth.ByMatchingAccountID 2 }}
|
||||||
@@ -8,7 +9,6 @@
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
{{- range $shop := $shops }}
|
{{- range $shop := $shops }}
|
||||||
{{- $listings := $dot.Accounts.GetListingsForShop $acctID $shop.ShopID }}
|
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
class="min-w-fit cursor-pointer"
|
class="min-w-fit cursor-pointer"
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<option disabled selected>- Listings -</option>
|
<option disabled selected>- Listings -</option>
|
||||||
|
{{/*- $listings := $dot.Accounts.GetListingsForShop $acctID $shop.ShopID }}
|
||||||
{{- range $listing := $listings }}
|
{{- range $listing := $listings }}
|
||||||
<option
|
<option
|
||||||
value="{{$listing.ListingID}}"
|
value="{{$listing.ListingID}}"
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
>
|
>
|
||||||
{{ $listing.Name }}
|
{{ $listing.Name }}
|
||||||
</option>
|
</option>
|
||||||
{{- end }}
|
{{- end */}}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
{{/* .ShopID: default to path param "shopID" */}}
|
|
||||||
|
|
||||||
{{- $shopID := or (and .PathParams .PathParams.shopID) .ShopID }}
|
|
||||||
|
|
||||||
{{ $acctID := .Identity.Account.AccountID }}
|
|
||||||
|
|
||||||
<select
|
|
||||||
class="min-w-fit cursor-pointer"
|
|
||||||
_="
|
|
||||||
on input
|
|
||||||
set dataset to (my selectedOptions)[0].dataset
|
|
||||||
send setListing(listing: dataset) to the closest <tr/>
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{- $listings := .Accounts.GetListingsForShop $acctID $shopID }}
|
|
||||||
{{- $dot := . }}
|
|
||||||
<option disabled selected>- Listings -</option>
|
|
||||||
{{- range $listing := $listings }}
|
|
||||||
<option
|
|
||||||
value="{{$listing.SKU}}"
|
|
||||||
data-name="{{ $listing.Name }}"
|
|
||||||
data-sku="{{ $listing.SKU }}"
|
|
||||||
data-description="{{ $listing.Description }}"
|
|
||||||
data-count="{{ $listing.Count }}"
|
|
||||||
>
|
|
||||||
{{ $listing.Name }}
|
|
||||||
</option>
|
|
||||||
{{- end }}
|
|
||||||
</select>
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{{/* .HXGet | .HXPost | .HXDelete, .HXTarget, .HXSwap, .HXVals, ._, .Text, .ID, .Class, .Disabled */}}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="
|
||||||
|
border-thin
|
||||||
|
bg-card
|
||||||
|
rounded-sm
|
||||||
|
p-[0.5em]
|
||||||
|
font-semibold
|
||||||
|
cursor-pointer
|
||||||
|
hover:underline
|
||||||
|
hover:bg-accent
|
||||||
|
disabled:bg-accent-secondary
|
||||||
|
disabled:cursor-not-allowed
|
||||||
|
disabled:no-underline
|
||||||
|
{{.Class}}
|
||||||
|
"
|
||||||
|
{{- if .ID }}
|
||||||
|
id="{{.ID}}"
|
||||||
|
{{- end }}
|
||||||
|
{{- if .HXGet }}
|
||||||
|
hx-get="{{.HXGet}}"
|
||||||
|
{{- else if .HXPost }}
|
||||||
|
hx-post="{{.HXPost}}"
|
||||||
|
{{- else if .HXDelete }}
|
||||||
|
hx-delete="{{.HXDelete}}"
|
||||||
|
{{- end }}
|
||||||
|
{{- if .HXTarget }}
|
||||||
|
hx-target="{{.HXTarget}}"
|
||||||
|
{{- end }}
|
||||||
|
{{- if .HXSwap }}
|
||||||
|
hx-swap="{{.HXSwap}}"
|
||||||
|
{{- end }}
|
||||||
|
{{- if .HXVals }}
|
||||||
|
hx-vals="{{.HXVals}}"
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Disabled}}
|
||||||
|
disabled
|
||||||
|
{{- end}}
|
||||||
|
{{- if ._ }}
|
||||||
|
_="{{ ._ }}"
|
||||||
|
{{- end }}
|
||||||
|
>
|
||||||
|
{{.Text}}
|
||||||
|
</button>
|
||||||
@@ -1,19 +1,55 @@
|
|||||||
{{- .Auth.ByMatchingAccountID 2 }}
|
{{- .Auth.ByMatchingAccountID 2 }}
|
||||||
|
|
||||||
|
{{- $acctID := .Identity.Account.AccountID }}
|
||||||
|
{{- $stores := .Accounts.GetShops $acctID -}}
|
||||||
|
|
||||||
|
|
||||||
{{- define "title" }} Inventory++ {{ end }}
|
{{- define "title" }} Inventory++ {{ end }}
|
||||||
|
|
||||||
<h1 class="text-center mb-[0.5em]">Inventory</h1>
|
<h1 class="text-center mb-[0.5em]">Inventory</h1>
|
||||||
|
|
||||||
<h2 class="text-center mb-[0.5em]">Sync Stores</h2>
|
<h2 class="text-center mb-[0.5em]">Synced Listings</h2>
|
||||||
|
|
||||||
{{ $acctID := .Identity.Account.AccountID }}
|
|
||||||
{{ $stores := .Accounts.GetShops $acctID }}
|
|
||||||
|
|
||||||
<section class="w-full flex flex-col items-center">
|
<section class="w-full flex flex-col items-center">
|
||||||
<h3>
|
<h3>
|
||||||
Draft 2
|
Create a Sync Group
|
||||||
</h3>
|
</h3>
|
||||||
|
<h4>
|
||||||
|
(Draft 2)
|
||||||
|
</h4>
|
||||||
{{ componentBody "accounts/{acctID}/inventory/sync-table-v2" "dot" . }}
|
{{ componentBody "accounts/{acctID}/inventory/sync-table-v2" "dot" . }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ 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 <tbody/> 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
|
||||||
|
`
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="w-full flex flex-col items-center">
|
<section class="w-full flex flex-col items-center">
|
||||||
@@ -28,12 +64,11 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="w-full flex justify-center mt-[1em]">
|
<section class="w-full flex justify-center mt-[1em]">
|
||||||
<button
|
{{ componentBody "button-dev"
|
||||||
class="border-thin bg-card rounded-sm p-[0.5em] font-semibold cursor-pointer hover:underline hover:bg-accent"
|
"HXGet" (printf "/accounts/%d/inventory/sync-table/new-row" $acctID)
|
||||||
hx-get="/accounts/{{$acctID}}/inventory/sync-table/new-row"
|
"HXTarget" "#inventory-table > tbody"
|
||||||
hx-target="#inventory-table > tbody"
|
"HXSwap" "beforeend"
|
||||||
hx-swap="beforeend"
|
"Class" "mt-[1em] mb-[1em]"
|
||||||
>
|
"Text" "Add Row"
|
||||||
Add Row
|
}}
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user