save "create sync group" table in database
This commit is contained in:
@@ -30,15 +30,11 @@ type (
|
||||
|
||||
AccountShop struct {
|
||||
AccountShopIDs
|
||||
Platform Platform
|
||||
Name string
|
||||
Name string
|
||||
}
|
||||
|
||||
Listing struct {
|
||||
AccountIDs
|
||||
Platform Platform
|
||||
ShopID string
|
||||
ListingID int64
|
||||
AccountShopListingIDs
|
||||
SKU string
|
||||
Name string
|
||||
Description string
|
||||
@@ -53,16 +49,14 @@ type (
|
||||
|
||||
AccountShopIDs struct {
|
||||
AccountIDs
|
||||
ShopID string
|
||||
Platform Platform
|
||||
ShopID string
|
||||
}
|
||||
|
||||
Platform string
|
||||
)
|
||||
|
||||
const (
|
||||
Etsy Platform = "Etsy"
|
||||
Tiktok Platform = "Tiktok"
|
||||
Wix Platform = "Wix"
|
||||
AccountShopListingIDs struct {
|
||||
AccountShopIDs
|
||||
ListingID string
|
||||
}
|
||||
)
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
@@ -300,12 +294,15 @@ func (db *Store) GetShops(ctx context.Context, acctID int64) ([]AccountShop, err
|
||||
return shops, nil
|
||||
}
|
||||
|
||||
func (db *Store) GetListingsForShop(ctx context.Context, acctID int64, shopID string) ([]Listing, error) {
|
||||
func (db *Store) GetListingsForShop(ctx context.Context, acctID int64, platform Platform, shopID string) ([]Listing, error) {
|
||||
var vs []Listing
|
||||
for _, v := range devListings {
|
||||
if v.AccountID != acctID {
|
||||
continue
|
||||
}
|
||||
if v.Platform != platform {
|
||||
continue
|
||||
}
|
||||
if v.ShopID != shopID {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,233 +7,281 @@ var (
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
ShopID: "2",
|
||||
Platform: Etsy,
|
||||
ShopID: "2",
|
||||
},
|
||||
Platform: Etsy,
|
||||
Name: "Etsy 1",
|
||||
Name: "Etsy 1",
|
||||
},
|
||||
AccountShop{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
ShopID: "5",
|
||||
Platform: Etsy,
|
||||
ShopID: "5",
|
||||
},
|
||||
Platform: Etsy,
|
||||
Name: "Etsy 2",
|
||||
Name: "Etsy 2",
|
||||
},
|
||||
AccountShop{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
ShopID: "8",
|
||||
Platform: Etsy,
|
||||
ShopID: "8",
|
||||
},
|
||||
Platform: Etsy,
|
||||
Name: "Etsy 3",
|
||||
Name: "Etsy 3",
|
||||
},
|
||||
AccountShop{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
ShopID: "11",
|
||||
Platform: Tiktok,
|
||||
ShopID: "11",
|
||||
},
|
||||
Platform: Tiktok,
|
||||
Name: "Tiktok 1",
|
||||
Name: "Tiktok 1",
|
||||
},
|
||||
AccountShop{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
ShopID: "14",
|
||||
Platform: Tiktok,
|
||||
ShopID: "14",
|
||||
},
|
||||
Platform: Tiktok,
|
||||
Name: "Tiktok 2",
|
||||
Name: "Tiktok 2",
|
||||
},
|
||||
AccountShop{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
ShopID: "17",
|
||||
Platform: Tiktok,
|
||||
ShopID: "17",
|
||||
},
|
||||
Platform: Tiktok,
|
||||
Name: "Tiktok 3",
|
||||
Name: "Tiktok 3",
|
||||
},
|
||||
AccountShop{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
ShopID: "20",
|
||||
Platform: Wix,
|
||||
ShopID: "20",
|
||||
},
|
||||
Platform: Wix,
|
||||
Name: "Wix 1",
|
||||
Name: "Wix 1",
|
||||
},
|
||||
AccountShop{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
ShopID: "23",
|
||||
Platform: Wix,
|
||||
ShopID: "23",
|
||||
},
|
||||
Platform: Wix,
|
||||
Name: "Wix 2",
|
||||
Name: "Wix 2",
|
||||
},
|
||||
AccountShop{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
ShopID: "26",
|
||||
Platform: Wix,
|
||||
ShopID: "26",
|
||||
},
|
||||
Platform: Wix,
|
||||
Name: "Wix 3",
|
||||
Name: "Wix 3",
|
||||
},
|
||||
}
|
||||
|
||||
devListings = []Listing{
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "1",
|
||||
},
|
||||
ListingID: "1",
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "1",
|
||||
ListingID: 1,
|
||||
SKU: "sku 1",
|
||||
Name: "name 1",
|
||||
Description: "description 1",
|
||||
Count: 51,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "1",
|
||||
},
|
||||
ListingID: "2",
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "1",
|
||||
ListingID: 2,
|
||||
SKU: "sku 2",
|
||||
Name: "name 2",
|
||||
Description: "description 2",
|
||||
Count: 52,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "1",
|
||||
},
|
||||
ListingID: "3",
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "1",
|
||||
ListingID: 3,
|
||||
SKU: "sku 3",
|
||||
Name: "name 3",
|
||||
Description: "description 3",
|
||||
Count: 53,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Tiktok,
|
||||
ShopID: "4",
|
||||
},
|
||||
ListingID: "4",
|
||||
},
|
||||
Platform: Tiktok,
|
||||
ShopID: "4",
|
||||
ListingID: 4,
|
||||
SKU: "sku 4",
|
||||
Name: "name 4",
|
||||
Description: "description 4",
|
||||
Count: 54,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Tiktok,
|
||||
ShopID: "4",
|
||||
},
|
||||
ListingID: "5",
|
||||
},
|
||||
Platform: Tiktok,
|
||||
ShopID: "4",
|
||||
ListingID: 5,
|
||||
SKU: "sku 5",
|
||||
Name: "name 5",
|
||||
Description: "description 5",
|
||||
Count: 55,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Tiktok,
|
||||
ShopID: "4",
|
||||
},
|
||||
ListingID: "6",
|
||||
},
|
||||
Platform: Tiktok,
|
||||
ShopID: "4",
|
||||
ListingID: 6,
|
||||
SKU: "sku 6",
|
||||
Name: "name 6",
|
||||
Description: "description 6",
|
||||
Count: 56,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Wix,
|
||||
ShopID: "7",
|
||||
},
|
||||
ListingID: "7",
|
||||
},
|
||||
Platform: Wix,
|
||||
ShopID: "7",
|
||||
ListingID: 7,
|
||||
SKU: "sku 7",
|
||||
Name: "name 7",
|
||||
Description: "description 7",
|
||||
Count: 57,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Wix,
|
||||
ShopID: "7",
|
||||
},
|
||||
ListingID: "8",
|
||||
},
|
||||
Platform: Wix,
|
||||
ShopID: "7",
|
||||
ListingID: 8,
|
||||
SKU: "sku 8",
|
||||
Name: "name 8",
|
||||
Description: "description 8",
|
||||
Count: 58,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Wix,
|
||||
ShopID: "7",
|
||||
},
|
||||
ListingID: "9",
|
||||
},
|
||||
Platform: Wix,
|
||||
ShopID: "7",
|
||||
ListingID: 9,
|
||||
SKU: "sku 9",
|
||||
Name: "name 9",
|
||||
Description: "description 9",
|
||||
Count: 59,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "2",
|
||||
},
|
||||
ListingID: "10",
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "2",
|
||||
ListingID: 10,
|
||||
SKU: "sku 10",
|
||||
Name: "name 10",
|
||||
Description: "description 10",
|
||||
Count: 60,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "2",
|
||||
},
|
||||
ListingID: "11",
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "2",
|
||||
ListingID: 11,
|
||||
SKU: "sku 11",
|
||||
Name: "name 11",
|
||||
Description: "description 11",
|
||||
Count: 61,
|
||||
},
|
||||
{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
AccountShopListingIDs: AccountShopListingIDs{
|
||||
AccountShopIDs: AccountShopIDs{
|
||||
AccountIDs: AccountIDs{
|
||||
AccountID: 6,
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "2",
|
||||
},
|
||||
ListingID: "12",
|
||||
},
|
||||
Platform: Etsy,
|
||||
ShopID: "2",
|
||||
ListingID: 12,
|
||||
SKU: "sku 12",
|
||||
Name: "name 12",
|
||||
Description: "description 12",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) GetListingsForShop(acctID int64, shopID string) ([]Listing, error) {
|
||||
return v_ctx.Store.GetListingsForShop(v_ctx.ctx, acctID, shopID)
|
||||
func (v_ctx *StoreWithContext) GetListingsForShop(acctID int64, platform Platform, shopID string) ([]Listing, error) {
|
||||
return v_ctx.Store.GetListingsForShop(v_ctx.ctx, acctID, platform, shopID)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
|
||||
return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) CreateSyncGroupListingDraft(acctID int64) (int, error) {
|
||||
return v_ctx.Store.CreateSyncGroupListingDraft(v_ctx.ctx, acctID)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) GetSyncGroupListingDraft(acctID int64, orderIndex int) (SyncGroupListingDraft, error) {
|
||||
return v_ctx.Store.GetSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) SetShopInSyncGroupListingDraft(acctID int64, orderIndex int, platform Platform, shopID string) error {
|
||||
return v_ctx.Store.SetShopInSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex, platform, shopID)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) SetListingInSyncGroupListingDraft(acctID int64, orderIndex int, listingID string) error {
|
||||
return v_ctx.Store.SetListingInSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex, listingID)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) DeleteSyncGroupListingDraft(acctID int64, orderIndex int) (int, error) {
|
||||
return v_ctx.Store.DeleteSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) GetSyncGroupListingDrafts(acctID int64) ([]SyncGroupListingDraft, error) {
|
||||
return v_ctx.Store.GetSyncGroupListingDrafts(v_ctx.ctx, acctID)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) SaveNewSyncGroup(acctID int64) (SyncGroup, error) {
|
||||
return v_ctx.Store.SaveNewSyncGroup(v_ctx.ctx, acctID)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"ruben/inventory2/internal/consts"
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"ruben/inventory2/internal/site/middleware"
|
||||
"ruben/inventory2/internal/site/response"
|
||||
)
|
||||
|
||||
@@ -17,7 +20,7 @@ func (s *Server) createAccount(r *http.Request) (response.Response, error) {
|
||||
return nil, response.BadRequest().Msg("no email provided")
|
||||
}
|
||||
|
||||
userID := getIdentity(ctx).User.UserID
|
||||
userID := middleware.GetIdentity(ctx).User.UserID
|
||||
|
||||
acct, err := s.accts.CreateAccount(ctx, userID, email)
|
||||
if err != nil {
|
||||
@@ -30,3 +33,125 @@ func (s *Server) createAccount(r *http.Request) (response.Response, error) {
|
||||
|
||||
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil
|
||||
}
|
||||
|
||||
// POST /accounts/{acctID}/inventory/sync-groups/draft/listings
|
||||
func (s *Server) createSyncGroupListingDraft(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
orderIndex, err := s.accts.CreateSyncGroupListingDraft(ctx, acctID)
|
||||
if err != nil {
|
||||
return nil, response.Errorf("failed to create new listing draft: %w", err)
|
||||
}
|
||||
|
||||
return response.Redirect(
|
||||
http.StatusSeeOther,
|
||||
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
|
||||
), nil
|
||||
}
|
||||
|
||||
// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop
|
||||
// @platform string
|
||||
// @shopID string
|
||||
func (s *Server) setShopInSyncGroupListingDraft(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
platformStr := r.FormValue("platform")
|
||||
platform, err := accounts.NewPlatform(platformStr)
|
||||
if err != nil {
|
||||
return nil, response.BadRequest().
|
||||
Msgf("unrecognized platform: %s", platformStr)
|
||||
}
|
||||
|
||||
shopID := r.FormValue("shop-id")
|
||||
if shopID == "" {
|
||||
return nil, response.BadRequest().
|
||||
Msg("no shop-id provided")
|
||||
}
|
||||
|
||||
if err := s.accts.SetShopInSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil {
|
||||
return nil, response.Errorf("failed to set shop: %w", mapConstantErrorsToHTTPErrors(err))
|
||||
}
|
||||
|
||||
return response.Redirect(
|
||||
http.StatusSeeOther,
|
||||
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
|
||||
), nil
|
||||
}
|
||||
|
||||
// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing
|
||||
func (s *Server) setListingInSyncGroupListingDraft(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listingID := r.FormValue("listing-id")
|
||||
if listingID == "" {
|
||||
return nil, response.BadRequest().
|
||||
Msg("no listing-id provided")
|
||||
}
|
||||
|
||||
if err := s.accts.SetListingInSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil {
|
||||
return nil, response.Errorf("failed to set listing: %w", mapConstantErrorsToHTTPErrors(err))
|
||||
}
|
||||
|
||||
return response.Redirect(
|
||||
http.StatusSeeOther,
|
||||
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
|
||||
), nil
|
||||
}
|
||||
|
||||
// DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}
|
||||
func (s *Server) deleteSyncGroupListingDraft(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.accts.DeleteSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil {
|
||||
return nil, response.Errorf("failed to delete listing: %w", mapConstantErrorsToHTTPErrors(err))
|
||||
}
|
||||
|
||||
return response.Status(200), nil
|
||||
}
|
||||
|
||||
// POST /accounts/{acctID}/inventory/sync-groups
|
||||
func (s *Server) saveNewSyncGroup(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
grp, err := s.accts.SaveNewSyncGroup(ctx, acctID)
|
||||
if err != nil {
|
||||
return nil, response.Errorf("failed to save new sync group: %w", mapConstantErrorsToHTTPErrors(err))
|
||||
}
|
||||
|
||||
return response.Redirect(
|
||||
http.StatusSeeOther,
|
||||
// TODO: template not implemented
|
||||
fmt.Sprintf("/accounts/%d/inventory/sync-groups/%d", acctID, grp.SyncGroupID),
|
||||
), nil
|
||||
}
|
||||
|
||||
func getOrderIndexForSyncGroupListingDraftFromPath(r *http.Request) (int, error) {
|
||||
orderIndexStr := r.PathValue("orderIndex")
|
||||
orderIndex, err := strconv.Atoi(orderIndexStr)
|
||||
if err != nil {
|
||||
return 0, response.NotFound().
|
||||
Msgf("no listing draft found at %s", orderIndexStr)
|
||||
}
|
||||
|
||||
return orderIndex, nil
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
func getExpiredCookie(name string) http.Cookie {
|
||||
func Expired(name string) http.Cookie {
|
||||
return http.Cookie{
|
||||
Name: name,
|
||||
Path: "/",
|
||||
+10
-34
@@ -4,15 +4,18 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/domains/authentication"
|
||||
"ruben/inventory2/internal/site/cookies"
|
||||
"ruben/inventory2/internal/site/response"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO: use a login/logout server? (prefix: '/auth'?)
|
||||
|
||||
// GET /login
|
||||
func (s *Server) loginPage(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
u, err := s.newLoginURL(ctx, "/")
|
||||
u, err := newLoginURL(ctx, s.auth, "/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -20,31 +23,15 @@ func (s *Server) loginPage(r *http.Request) (response.Response, error) {
|
||||
return response.TemporaryRedirect(u), nil
|
||||
}
|
||||
|
||||
func (s *Server) newLoginURL(ctx context.Context, targetURI string) (string, error) {
|
||||
state, err := s.auth.NewState(ctx, targetURI)
|
||||
func newLoginURL(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) {
|
||||
state, err := auth.NewState(ctx, targetURI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random state: %w", err)
|
||||
}
|
||||
|
||||
base64EncodedState := fmt.Sprintf("%x", state[:])
|
||||
|
||||
return s.auth.AuthCodeURL(base64EncodedState), nil
|
||||
}
|
||||
|
||||
// POST /login
|
||||
func (s *Server) login(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
email := r.FormValue("email")
|
||||
if email == "" {
|
||||
return nil, response.BadRequest().Msg("no email provided")
|
||||
}
|
||||
|
||||
acct, err := s.accts.GetAccountByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, response.Errorf("failed to create account: %w", err)
|
||||
}
|
||||
|
||||
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil
|
||||
return auth.AuthCodeURL(base64EncodedState), nil
|
||||
}
|
||||
|
||||
// GET /login/callback
|
||||
@@ -64,18 +51,7 @@ func (s *Server) loginCallback(r *http.Request) (response.Response, error) {
|
||||
// set access_token cookie and redirect to a reasonable place
|
||||
|
||||
return response.TemporaryRedirect(targetURI).
|
||||
Cookie(newAccessTokenCookie(accessToken, expiration)), nil
|
||||
}
|
||||
|
||||
func newAccessTokenCookie(tkn string, expiration time.Time) http.Cookie {
|
||||
return http.Cookie{
|
||||
Name: "access_token",
|
||||
Value: tkn,
|
||||
Path: "/",
|
||||
Expires: expiration,
|
||||
MaxAge: 0, // using Expiration instead
|
||||
Secure: true,
|
||||
}
|
||||
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
||||
}
|
||||
|
||||
// GET /logout
|
||||
@@ -92,5 +68,5 @@ func (s *Server) logoutPage(r *http.Request) (response.Response, error) {
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
|
||||
Cookie(getExpiredCookie("access_token")), nil
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package site
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -7,26 +7,49 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ruben/inventory2/internal/consts"
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"ruben/inventory2/internal/domains/authentication"
|
||||
"ruben/inventory2/internal/site/cookies"
|
||||
"ruben/inventory2/internal/site/response"
|
||||
)
|
||||
|
||||
type identity struct {
|
||||
AccessToken string
|
||||
Claims authentication.AccessTokenClaims
|
||||
User accounts.OAuthUser
|
||||
Account *accounts.Account
|
||||
type (
|
||||
Auth struct {
|
||||
auth *authentication.Authenticator
|
||||
newLoginURL LoginURLProviderFunc
|
||||
accts *accounts.Store
|
||||
}
|
||||
|
||||
Identity struct {
|
||||
AccessToken string
|
||||
Claims authentication.AccessTokenClaims
|
||||
User accounts.OAuthUser
|
||||
Account *accounts.Account
|
||||
}
|
||||
|
||||
LoginURLProviderFunc = func(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error)
|
||||
|
||||
AuthorizationAssertions = response.HandlerFunc
|
||||
)
|
||||
|
||||
func NewAuth(
|
||||
auth *authentication.Authenticator,
|
||||
newLoginURL LoginURLProviderFunc,
|
||||
accts *accounts.Store,
|
||||
) *Auth {
|
||||
return &Auth{
|
||||
auth: auth,
|
||||
newLoginURL: newLoginURL,
|
||||
accts: accts,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) addIdentity(fn response.HandlerFunc) response.HandlerFunc {
|
||||
func (a *Auth) AddIdentity(fn response.HandlerFunc) response.HandlerFunc {
|
||||
return func(r *http.Request) (response.Response, error) {
|
||||
r, err := s.addIdentityToRequest(r)
|
||||
r, err := a.AddIdentityToRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -35,7 +58,7 @@ func (s *Server) addIdentity(fn response.HandlerFunc) response.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
|
||||
func (a *Auth) AddIdentityToRequest(r *http.Request) (*http.Request, error) {
|
||||
ck, err := r.Cookie("access_token")
|
||||
if err != nil {
|
||||
return r, nil
|
||||
@@ -45,7 +68,7 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
|
||||
|
||||
accessToken := ck.Value
|
||||
|
||||
claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||
claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, consts.ErrNotFound) {
|
||||
return r, nil
|
||||
@@ -58,12 +81,12 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
user, acct, err := s.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
return r, response.Errorf("failed to load user and account defails: %w", err)
|
||||
}
|
||||
|
||||
return r.WithContext(setIdentity(ctx, identity{
|
||||
return r.WithContext(SetIdentity(ctx, Identity{
|
||||
AccessToken: accessToken,
|
||||
Claims: claims,
|
||||
User: user,
|
||||
@@ -75,7 +98,7 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
|
||||
// - then consider doing the same with authorizationAssertions
|
||||
|
||||
// auth middleware to verify access_token cookie and set custom claims in the request context
|
||||
func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions ...authorizationAssertions) response.HandlerFunc {
|
||||
func (a *Auth) AuthenticateAndAddIdentity(f response.HandlerFunc, assertions ...AuthorizationAssertions) response.HandlerFunc {
|
||||
return func(r *http.Request) (response.Response, error) {
|
||||
ck, err := r.Cookie("access_token")
|
||||
if err != nil {
|
||||
@@ -87,10 +110,10 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions .
|
||||
|
||||
accessToken := ck.Value
|
||||
|
||||
claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||
claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, consts.ErrNotFound) {
|
||||
u, err := s.newLoginURL(ctx, r.URL.String())
|
||||
u, err := a.newLoginURL(ctx, a.auth, r.URL.String())
|
||||
if err != nil {
|
||||
return nil, response.Errorf("failed to generate login url: %w", err)
|
||||
}
|
||||
@@ -108,22 +131,22 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions .
|
||||
// id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in.
|
||||
const idTokenLifetime = 48 * time.Hour
|
||||
if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) {
|
||||
accessToken, expiration, err = s.auth.RefreshAccessToken(ctx, accessToken)
|
||||
accessToken, expiration, err = a.auth.RefreshAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
fmt.Println("failed to refresh access token:", err)
|
||||
return response.TemporaryRedirect("/").
|
||||
Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))).
|
||||
Cookie(getExpiredCookie("access_token")), nil
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
|
||||
// 'redirect' to same url, to set the new access_token cookie
|
||||
return response.TemporaryRedirect(r.URL.String()).
|
||||
Cookie(newAccessTokenCookie(accessToken, expiration)), nil
|
||||
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
||||
}
|
||||
|
||||
// add identity info to request context
|
||||
|
||||
user, acct, err := s.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
return nil, response.Errorf("failed to authorize: %w", err)
|
||||
}
|
||||
@@ -134,7 +157,7 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions .
|
||||
}
|
||||
}
|
||||
|
||||
return f(r.WithContext(setIdentity(ctx, identity{
|
||||
return f(r.WithContext(SetIdentity(ctx, Identity{
|
||||
AccessToken: accessToken,
|
||||
Claims: claims,
|
||||
User: user,
|
||||
@@ -143,65 +166,15 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions .
|
||||
}
|
||||
}
|
||||
|
||||
type authorizationAssertions = response.HandlerFunc
|
||||
|
||||
// TODO: test this!
|
||||
func authorizeByMatchingAccountID_tmp(acctIDPathPosition int) authorizationAssertions {
|
||||
return func(r *http.Request) (response.Response, error) {
|
||||
return nil, authorizeByMatchingAccountID(r, acctIDPathPosition)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getAccessTokenClaims(r *http.Request) (authentication.AccessTokenClaims, bool) {
|
||||
ck, err := r.Cookie("access_token")
|
||||
if err != nil {
|
||||
return authentication.AccessTokenClaims{}, false
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, ck.Value)
|
||||
if err != nil {
|
||||
return authentication.AccessTokenClaims{}, false
|
||||
}
|
||||
if expiration.Before(time.Now()) {
|
||||
return authentication.AccessTokenClaims{}, false
|
||||
}
|
||||
|
||||
return claims, true
|
||||
}
|
||||
|
||||
type identityKey struct{}
|
||||
|
||||
// stores identity in request context
|
||||
func setIdentity(ctx context.Context, id identity) context.Context {
|
||||
func SetIdentity(ctx context.Context, id Identity) context.Context {
|
||||
return context.WithValue(ctx, identityKey{}, id)
|
||||
}
|
||||
|
||||
// get identity from request context
|
||||
func getIdentity(ctx context.Context) identity {
|
||||
id, _ := ctx.Value(identityKey{}).(identity)
|
||||
func GetIdentity(ctx context.Context) Identity {
|
||||
id, _ := ctx.Value(identityKey{}).(Identity)
|
||||
return id
|
||||
}
|
||||
|
||||
func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error {
|
||||
pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/")
|
||||
if len(pathParts) < acctIDPathPosition {
|
||||
return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
|
||||
part := pathParts[acctIDPathPosition-1]
|
||||
acctID, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return response.NotFound().
|
||||
Msgf("account does not exist: %s", part)
|
||||
}
|
||||
|
||||
id := getIdentity(r.Context())
|
||||
if id.Account == nil || id.Account.AccountID != acctID {
|
||||
return response.Unauthorized().
|
||||
Msgf("user does not have access to account %d", acctID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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) {
|
||||
http.Error(w, err.Error(), GetStatusFromError(err))
|
||||
}
|
||||
|
||||
func GetStatusFromError(err error) int {
|
||||
status := http.StatusInternalServerError
|
||||
|
||||
if e, ok := GetError(err); ok {
|
||||
@@ -56,5 +60,5 @@ func WriteError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), status)
|
||||
return status
|
||||
}
|
||||
|
||||
+102
-29
@@ -11,21 +11,24 @@ import (
|
||||
|
||||
"github.com/angelbeltran/templater"
|
||||
|
||||
"ruben/inventory2/internal/consts"
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"ruben/inventory2/internal/domains/authentication"
|
||||
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/site/middleware"
|
||||
"ruben/inventory2/internal/site/response"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
http.Handler
|
||||
contentDir string
|
||||
templater *templater.Templater
|
||||
rawEvents *raw_events.Store
|
||||
accts *accounts.Store
|
||||
etsy *etsy_platform.Platform
|
||||
auth *authentication.Authenticator
|
||||
contentDir string
|
||||
templater *templater.Templater
|
||||
rawEvents *raw_events.Store
|
||||
accts *accounts.Store
|
||||
etsy *etsy_platform.Platform
|
||||
auth *authentication.Authenticator
|
||||
authMiddleware *middleware.Auth
|
||||
}
|
||||
|
||||
func NewServer(
|
||||
@@ -35,14 +38,28 @@ func NewServer(
|
||||
etsy *etsy_platform.Platform,
|
||||
auth *authentication.Authenticator,
|
||||
) *Server {
|
||||
mux := http.NewServeMux()
|
||||
mux := response.NewMux(func(fn response.HandlerFunc) response.HandlerFunc {
|
||||
return func(r *http.Request) (response.Response, error) {
|
||||
res, err := fn(r)
|
||||
if err != nil {
|
||||
status := response.GetStatusFromError(err)
|
||||
|
||||
// TODO: get better logger
|
||||
fmt.Printf("[ERROR]: %d: %s; %s\n", status, r.URL, err)
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
})
|
||||
|
||||
s := &Server{
|
||||
Handler: mux,
|
||||
contentDir: contentDir,
|
||||
templater: templater.NewTemplater(
|
||||
contentDir+"/templates",
|
||||
func() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
// paths
|
||||
"buildSitePath": func(parts ...any) string {
|
||||
strParts := make([]string, len(parts))
|
||||
for i, p := range parts {
|
||||
@@ -60,18 +77,30 @@ func NewServer(
|
||||
return strings.Split(strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/"), "/")
|
||||
},
|
||||
|
||||
"prettyPrintJSON": func(j json.RawMessage) string {
|
||||
b, err := json.MarshalIndent(j, " ", "")
|
||||
if err != nil {
|
||||
return string(j)
|
||||
// params
|
||||
"addPathParam": func(k string, v any, args map[string]any) (map[string]any, error) {
|
||||
pathParams, ok := args["PathParams"].(map[string]string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("PathParams no set are args: %v", args)
|
||||
}
|
||||
return string(b)
|
||||
|
||||
pathParams[k] = fmt.Sprint(v)
|
||||
|
||||
return args, nil
|
||||
},
|
||||
|
||||
// parsing
|
||||
"parseInt": func(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
},
|
||||
"parseInt64": func(s string) (int64, error) {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
},
|
||||
"parsePlatform": func(s string) (accounts.Platform, error) {
|
||||
return accounts.NewPlatform(s)
|
||||
},
|
||||
|
||||
// arithmetic
|
||||
"addInt": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
@@ -81,6 +110,15 @@ func NewServer(
|
||||
"multInt": func(a, b int) int {
|
||||
return a * b
|
||||
},
|
||||
|
||||
// json
|
||||
"prettyPrintJSON": func(j json.RawMessage) string {
|
||||
b, err := json.MarshalIndent(j, " ", "")
|
||||
if err != nil {
|
||||
return string(j)
|
||||
}
|
||||
return string(b)
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -88,39 +126,74 @@ func NewServer(
|
||||
accts: accts,
|
||||
etsy: etsy,
|
||||
auth: auth,
|
||||
authMiddleware: middleware.NewAuth(
|
||||
auth,
|
||||
newLoginURL,
|
||||
accts,
|
||||
),
|
||||
}
|
||||
|
||||
// api routes
|
||||
withAuth := func(fn response.HandlerFunc) response.HandlerFunc {
|
||||
return s.authMiddleware.AuthenticateAndAddIdentity(fn)
|
||||
}
|
||||
|
||||
mux.Handle("GET /login", response.Handler(s.loginPage))
|
||||
mux.Handle("GET /login/callback", response.Handler(s.loginCallback))
|
||||
mux.Handle("GET /logout", response.Handler(s.logoutPage))
|
||||
mux.Handle("POST /accounts", response.Handler(s.authenticateAndAddIdentity(s.createAccount)))
|
||||
// login
|
||||
|
||||
// TODO: eliminate once no longer used.
|
||||
mux.HandleFunc("POST /login", response.Handler(s.login))
|
||||
mux.Handle("GET /login", s.loginPage)
|
||||
mux.Handle("GET /login/callback", s.loginCallback)
|
||||
mux.Handle("GET /logout", s.logoutPage)
|
||||
|
||||
// /accounts
|
||||
|
||||
mux.Handle("POST /accounts", withAuth(s.createAccount))
|
||||
mux.Handle("POST /accounts/{acctID}/inventory/sync-groups/draft/listings", withAuth(s.createSyncGroupListingDraft))
|
||||
mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop", withAuth(s.setShopInSyncGroupListingDraft))
|
||||
mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing", withAuth(s.setListingInSyncGroupListingDraft))
|
||||
mux.Handle("DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}", withAuth(s.deleteSyncGroupListingDraft))
|
||||
mux.Handle("POST /accounts/{acctID}/inventory/sync-groups", withAuth(s.saveNewSyncGroup))
|
||||
|
||||
// webpage content
|
||||
|
||||
// non-html content: scripts, styles, images, etc
|
||||
|
||||
scfs := http.FileServer(http.Dir(contentDir + "/scripts"))
|
||||
mux.Handle("GET /scripts/", http.StripPrefix("/scripts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Mux.Handle("GET /scripts/", http.StripPrefix("/scripts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
if path.Ext(r.URL.Path) == ".gz" {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
}
|
||||
scfs.ServeHTTP(w, r)
|
||||
})))
|
||||
mux.Handle("GET /styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(contentDir+"/styles"))))
|
||||
mux.Mux.Handle("GET /styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(contentDir+"/styles"))))
|
||||
|
||||
// webpages
|
||||
// html
|
||||
|
||||
// all non-authenticated webpages
|
||||
mux.HandleFunc("GET /{$}", response.Handler(s.addIdentity(s.serveTemplates)))
|
||||
|
||||
// all authenticated webpages
|
||||
mux.HandleFunc("GET /", response.Handler(s.authenticateAndAddIdentity(s.serveTemplates)))
|
||||
|
||||
s.Handler = mux
|
||||
// non-authenticated
|
||||
mux.Handle("GET /{$}", s.authMiddleware.AddIdentity(s.serveTemplates))
|
||||
// authenticated
|
||||
mux.Handle("GET /", withAuth(s.serveTemplates))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func mapConstantErrorsToHTTPErrors(err error) error {
|
||||
cerr := err
|
||||
for cerr != nil {
|
||||
switch cerr {
|
||||
case consts.ErrNotFound:
|
||||
return response.NotFound()
|
||||
case consts.ErrConflict:
|
||||
return response.Conflict()
|
||||
}
|
||||
|
||||
uerr, ok := cerr.(interface {
|
||||
Unwrap() error
|
||||
})
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
cerr = uerr.Unwrap()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
--text-lg--line-height: calc(1.75 / 1.125);
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-8xl: 6rem;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--radius-lg: var(--radius);
|
||||
@@ -196,6 +195,9 @@
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.min-h-full {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -235,6 +237,9 @@
|
||||
.gap-y-\[2em\] {
|
||||
row-gap: 2em;
|
||||
}
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.overflow-x-scroll {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
@@ -314,6 +319,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled\:cursor-not-allowed {
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
.disabled\:bg-accent-secondary {
|
||||
&:disabled {
|
||||
background-color: var(--accent-secondary);
|
||||
}
|
||||
}
|
||||
.disabled\:no-underline {
|
||||
&:disabled {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
select {
|
||||
@@ -376,6 +396,7 @@
|
||||
--muted: var(--base-100);
|
||||
--muted-foreground: var(--base-600);
|
||||
--accent: var(--base-100);
|
||||
--accent-secondary: var(--base-300);
|
||||
--accent-foreground: var(--base-800);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: var(--base-200);
|
||||
@@ -417,6 +438,7 @@
|
||||
--muted: var(--base-800);
|
||||
--muted-foreground: var(--base-300);
|
||||
--accent: var(--base-800);
|
||||
--accent-secondary: var(--base-600);
|
||||
--accent-foreground: var(--base-200);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: var(--base-800);
|
||||
@@ -462,6 +484,7 @@
|
||||
--muted: var(--base-800);
|
||||
--muted-foreground: var(--base-300);
|
||||
--accent: var(--base-800);
|
||||
--accent-secondary: var(--base-600);
|
||||
--accent-foreground: var(--base-200);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: var(--base-800);
|
||||
@@ -491,23 +514,23 @@
|
||||
font-weight: var(--display-weight);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-5xl);
|
||||
font-size: var(--text-8xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-4xl);
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
h5 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h6 {
|
||||
h5 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h6 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
--muted: var(--base-100);
|
||||
--muted-foreground: var(--base-600);
|
||||
--accent: var(--base-100);
|
||||
--accent-secondary: var(--base-300);
|
||||
--accent-foreground: var(--base-800);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: var(--base-200);
|
||||
@@ -104,6 +105,7 @@
|
||||
--muted: var(--base-800);
|
||||
--muted-foreground: var(--base-300);
|
||||
--accent: var(--base-800);
|
||||
--accent-secondary: var(--base-600);
|
||||
--accent-foreground: var(--base-200);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: var(--base-800);
|
||||
@@ -152,6 +154,7 @@
|
||||
--muted: var(--base-800);
|
||||
--muted-foreground: var(--base-300);
|
||||
--accent: var(--base-800);
|
||||
--accent-secondary: var(--base-600);
|
||||
--accent-foreground: var(--base-200);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: var(--base-800);
|
||||
@@ -236,6 +239,7 @@
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent-secondary: var(--accent-secondary);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
@@ -268,23 +272,23 @@
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-5xl);
|
||||
font-size: var(--text-8xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-4xl);
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
h5 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h6 {
|
||||
h5 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h6 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
|
||||
+30
-32
@@ -1,16 +1,16 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"ruben/inventory2/internal/site/middleware"
|
||||
"ruben/inventory2/internal/site/response"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ func (s *Server) getTemplateNameAndArgs(r *http.Request, templateDir string) (na
|
||||
Account *accounts.Account
|
||||
*/
|
||||
"Identity",
|
||||
getIdentity(r.Context()),
|
||||
middleware.GetIdentity(r.Context()),
|
||||
"Auth",
|
||||
newTemplateAuthenticator(r),
|
||||
}
|
||||
@@ -136,37 +136,13 @@ func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(file
|
||||
}
|
||||
|
||||
func (s *Server) handleTemplateError(err error, templateArgs ...any) (response.Response, error) {
|
||||
code := getHTTPStatusCode(err)
|
||||
|
||||
if code == http.StatusNotFound ||
|
||||
code == http.StatusForbidden ||
|
||||
code == http.StatusUnauthorized ||
|
||||
isFileNotFoundError(err) {
|
||||
|
||||
b, err := s.templater.ExecutePage("not-found", templateArgs...)
|
||||
if err != nil {
|
||||
fmt.Println("failed to render not found page:", err)
|
||||
return nil, response.NotFound().
|
||||
Wrap(err).
|
||||
Msg("resource not found")
|
||||
}
|
||||
|
||||
return response.Body(io.NopCloser(bytes.NewBuffer(b))), nil
|
||||
if isFileNotFoundError(err) {
|
||||
return nil, response.NotFound().
|
||||
Wrap(err).
|
||||
Msg("resource not found")
|
||||
}
|
||||
|
||||
if code == http.StatusConflict {
|
||||
b, err := s.templater.ExecutePage("conflict", templateArgs...)
|
||||
if err != nil {
|
||||
fmt.Println("failed to render conflict page:", err)
|
||||
return nil, response.Conflict().
|
||||
Wrap(err).
|
||||
Msg("conflict")
|
||||
}
|
||||
|
||||
return response.Body(io.NopCloser(bytes.NewBuffer(b))), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to render page: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func isFileNotFoundError(err error) bool {
|
||||
@@ -230,3 +206,25 @@ type templateAuthorizationFunc = func() (string, error)
|
||||
func (a *templateAuthenticator) ByMatchingAccountID(acctIDPathPosition int) (string, error) {
|
||||
return "", authorizeByMatchingAccountID(a.req, acctIDPathPosition)
|
||||
}
|
||||
|
||||
func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error {
|
||||
pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/")
|
||||
if len(pathParts) < acctIDPathPosition {
|
||||
return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
|
||||
part := pathParts[acctIDPathPosition-1]
|
||||
acctID, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return response.NotFound().
|
||||
Msgf("account does not exist: %s", part)
|
||||
}
|
||||
|
||||
id := middleware.GetIdentity(r.Context())
|
||||
if id.Account == nil || id.Account.AccountID != acctID {
|
||||
return response.Unauthorized().
|
||||
Msgf("user does not have access to account %d", acctID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+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 . }}
|
||||
{{- .Auth.ByMatchingAccountID 2 }}
|
||||
|
||||
@@ -16,7 +14,7 @@
|
||||
{{- $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>
|
||||
<tr>
|
||||
<th>
|
||||
@@ -36,11 +34,36 @@
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{/* TODO: list existing row */}}
|
||||
{{- componentBody "accounts/{acctID}/inventory/sync-table-v2/new-row" "dot" $dot }}
|
||||
{{/* TODO: save the saved rows */}}
|
||||
<tbody
|
||||
_="
|
||||
init
|
||||
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>
|
||||
<tfoot>
|
||||
<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.Auth.ByMatchingAccountID 2 }}
|
||||
@@ -8,7 +9,6 @@
|
||||
|
||||
<tr>
|
||||
{{- range $shop := $shops }}
|
||||
{{- $listings := $dot.Accounts.GetListingsForShop $acctID $shop.ShopID }}
|
||||
<td>
|
||||
<select
|
||||
class="min-w-fit cursor-pointer"
|
||||
@@ -30,6 +30,7 @@
|
||||
"
|
||||
>
|
||||
<option disabled selected>- Listings -</option>
|
||||
{{/*- $listings := $dot.Accounts.GetListingsForShop $acctID $shop.ShopID }}
|
||||
{{- range $listing := $listings }}
|
||||
<option
|
||||
value="{{$listing.ListingID}}"
|
||||
@@ -40,7 +41,7 @@
|
||||
>
|
||||
{{ $listing.Name }}
|
||||
</option>
|
||||
{{- end }}
|
||||
{{- end */}}
|
||||
</select>
|
||||
</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 }}
|
||||
|
||||
{{- $acctID := .Identity.Account.AccountID }}
|
||||
{{- $stores := .Accounts.GetShops $acctID -}}
|
||||
|
||||
|
||||
{{- define "title" }} Inventory++ {{ end }}
|
||||
|
||||
<h1 class="text-center mb-[0.5em]">Inventory</h1>
|
||||
|
||||
<h2 class="text-center mb-[0.5em]">Sync Stores</h2>
|
||||
|
||||
{{ $acctID := .Identity.Account.AccountID }}
|
||||
{{ $stores := .Accounts.GetShops $acctID }}
|
||||
<h2 class="text-center mb-[0.5em]">Synced Listings</h2>
|
||||
|
||||
<section class="w-full flex flex-col items-center">
|
||||
<h3>
|
||||
Draft 2
|
||||
Create a Sync Group
|
||||
</h3>
|
||||
<h4>
|
||||
(Draft 2)
|
||||
</h4>
|
||||
{{ 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 class="w-full flex flex-col items-center">
|
||||
@@ -28,12 +64,11 @@
|
||||
</section>
|
||||
|
||||
<section class="w-full flex justify-center mt-[1em]">
|
||||
<button
|
||||
class="border-thin bg-card rounded-sm p-[0.5em] font-semibold cursor-pointer hover:underline hover:bg-accent"
|
||||
hx-get="/accounts/{{$acctID}}/inventory/sync-table/new-row"
|
||||
hx-target="#inventory-table > tbody"
|
||||
hx-swap="beforeend"
|
||||
>
|
||||
Add Row
|
||||
</button>
|
||||
{{ componentBody "button-dev"
|
||||
"HXGet" (printf "/accounts/%d/inventory/sync-table/new-row" $acctID)
|
||||
"HXTarget" "#inventory-table > tbody"
|
||||
"HXSwap" "beforeend"
|
||||
"Class" "mt-[1em] mb-[1em]"
|
||||
"Text" "Add Row"
|
||||
}}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user