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)
- [ ] ...
- [ ] Dark mode
- [ ] don't let a listing be in multiple sync groups
- [ ] Next stores on the list (at least hypothetically)
- Shopify
- 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 //
}
entity "**sync_group_listing_drafts**" {
+ ""account_id"": //integer [PK][FK]//
+ ""order_index"": //integer [PK]//
--
""platform"": //platform //
""shop_id"": //text //
""listing_id"": //text //
}
entity "**sync_group_listings**" {
+ ""sync_group_id"": //integer [PK][FK]//
+ ""order_index"": //integer [PK]//
--
""platform"": //platform //
""shop_id"": //text //
""listing_id"": //text //
}
entity "**sync_groups**" {
+ ""sync_group_id"": //serial [PK]//
--
*""account_id"": //integer [FK]//
}
entity "**tiktok_store_events**" {
+ ""store_id"": //text [PK][FK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
@@ -119,6 +143,12 @@ entity "**wix_store_events**" {
"**oauth_tokens**" }-- "**oauth_users**"
"**sync_group_listing_drafts**" }-- "**accounts**"
"**sync_group_listings**" }-- "**sync_groups**"
"**sync_groups**" }-- "**accounts**"
"**tiktok_store_events**" }-- "**raw_store_events**"
"**tiktok_store_events**" }-- "**raw_store_events**"
+12 -15
View File
@@ -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
}
+135 -87
View File
@@ -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",
+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)
}
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)
}
+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"
"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
}
+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"
func getExpiredCookie(name string) http.Cookie {
func Expired(name string) http.Cookie {
return http.Cookie{
Name: name,
Path: "/",
+10 -34
View File
@@ -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
}
+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) {
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
View File
@@ -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
}
+31 -8
View File
@@ -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;
}
+10 -6
View File
@@ -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
View File
@@ -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
}
@@ -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 . }}
{{- .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>
@@ -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.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>
@@ -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>