save "create sync group" table in database

This commit is contained in:
2026-01-11 05:19:20 -07:00
parent a7c8fe4d64
commit 889aead6b6
28 changed files with 1396 additions and 408 deletions
+1
View File
@@ -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

+30
View File
@@ -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**"
+10 -13
View File
@@ -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
} }
+69 -21
View File
@@ -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",
+54
View File
@@ -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)
}
+436
View File
@@ -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
View File
@@ -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
}
+17
View File
@@ -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
View File
@@ -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
}
+34
View File
@@ -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)
}
+5 -1
View File
@@ -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
View File
@@ -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
}
+31 -8
View File
@@ -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;
} }
+10 -6
View File
@@ -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
View File
@@ -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
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>