diff --git a/diagrams/database_schema_mock.svg b/diagrams/database_schema_mock.svg index 094b2b9..e8c2191 100644 --- a/diagrams/database_schema_mock.svg +++ b/diagrams/database_schema_mock.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/diagrams/database_schema_mock.uml b/diagrams/database_schema_mock.uml index 85c73d5..1927ed5 100644 --- a/diagrams/database_schema_mock.uml +++ b/diagrams/database_schema_mock.uml @@ -28,6 +28,15 @@ entity "**shop_amazon_listings**" { *""description"": //text // } +entity "**shop_amazon_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text [FK]// + ""listing_id"": //text // +} + entity "**shop_amazon_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK][FK]// @@ -65,6 +74,15 @@ entity "**shop_big_cartel_listings**" { *""description"": //text // } +entity "**shop_big_cartel_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text // + ""listing_id"": //text // +} + entity "**shop_big_cartel_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK][FK]// @@ -102,6 +120,15 @@ entity "**shop_ebay_listings**" { *""description"": //text // } +entity "**shop_ebay_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text [FK]// + ""listing_id"": //text // +} + entity "**shop_ebay_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK]// @@ -139,6 +166,15 @@ entity "**shop_ecwid_listings**" { *""description"": //text // } +entity "**shop_ecwid_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text [FK]// + ""listing_id"": //text // +} + entity "**shop_ecwid_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK][FK]// @@ -176,6 +212,15 @@ entity "**shop_etsy_listings**" { *""description"": //text // } +entity "**shop_etsy_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK]// + -- + *""account_id"": //integer // + *""shop_id"": //text [FK]// + ""listing_id"": //text // +} + entity "**shop_etsy_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK]// @@ -213,6 +258,15 @@ entity "**shop_shopify_listings**" { *""description"": //text // } +entity "**shop_shopify_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text // + ""listing_id"": //text // +} + entity "**shop_shopify_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK][FK]// @@ -250,6 +304,15 @@ entity "**shop_square_online_listings**" { *""description"": //text // } +entity "**shop_square_online_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text // + ""listing_id"": //text // +} + entity "**shop_square_online_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK][FK]// @@ -287,6 +350,15 @@ entity "**shop_squarespace_listings**" { *""description"": //text // } +entity "**shop_squarespace_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text // + ""listing_id"": //text // +} + entity "**shop_squarespace_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK][FK]// @@ -324,6 +396,15 @@ entity "**shop_tiktok_listings**" { *""description"": //text // } +entity "**shop_tiktok_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text [FK]// + ""listing_id"": //text // +} + entity "**shop_tiktok_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK][FK]// @@ -361,6 +442,15 @@ entity "**shop_walmart_marketplace_listings**" { *""description"": //text // } +entity "**shop_walmart_marketplace_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text // + ""listing_id"": //text // +} + entity "**shop_walmart_marketplace_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK][FK]// @@ -398,6 +488,15 @@ entity "**shop_wix_listings**" { *""description"": //text // } +entity "**shop_wix_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text [FK]// + ""listing_id"": //text // +} + entity "**shop_wix_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK]// @@ -435,6 +534,15 @@ entity "**shop_woo_commerce_listings**" { *""description"": //text // } +entity "**shop_woo_commerce_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text // + ""listing_id"": //text // +} + entity "**shop_woo_commerce_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK][FK]// @@ -472,6 +580,15 @@ entity "**shop_zoho_listings**" { *""description"": //text // } +entity "**shop_zoho_sync_group_editing_listings**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""shop_id"": //text [FK]// + ""listing_id"": //text // +} + entity "**shop_zoho_sync_group_listing_drafts**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK]// @@ -490,6 +607,26 @@ entity "**shop_zoho_sync_group_listings**" { *""order_index"": //integer // } +entity "**sync_group_drafts**" { + + ""account_id"": //integer [PK][FK]// + -- + ""sync_group_id"": //integer [FK]// +} + +entity "**sync_group_editing**" { + + ""account_id"": //integer [PK][FK]// + -- + *""account_id"": //integer // + *""sync_group_id"": //integer [FK]// + *""sync_group_id"": //integer // +} + +entity "**sync_group_editing_listing_order_indexes**" { + + ""account_id"": //integer [PK][FK]// + + ""order_index"": //integer [PK]// + -- +} + entity "**sync_group_listing_draft_order_indexes**" { + ""account_id"": //integer [PK][FK]// + ""order_index"": //integer [PK]// @@ -521,6 +658,12 @@ entity "**sync_groups**" { "**shop_amazon_listings**" ||-|| "**shop_amazon**" +"**shop_amazon_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_amazon_sync_group_editing_listings**" }-- "**sync_group_editing**" + +"**shop_amazon_sync_group_editing_listings**" }-- "**shop_amazon**" + "**shop_amazon_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" "**shop_amazon_sync_group_listing_drafts**" }-- "**accounts**" @@ -545,6 +688,12 @@ entity "**sync_groups**" { "**shop_big_cartel_listings**" ||-|| "**shop_big_cartel**" +"**shop_big_cartel_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_big_cartel_sync_group_editing_listings**" }-- "**shop_big_cartel**" + +"**shop_big_cartel_sync_group_editing_listings**" }-- "**sync_group_editing**" + "**shop_big_cartel_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" "**shop_big_cartel_sync_group_listing_drafts**" }-- "**accounts**" @@ -569,6 +718,12 @@ entity "**sync_groups**" { "**shop_ebay_listings**" ||-|| "**shop_ebay**" +"**shop_ebay_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_ebay_sync_group_editing_listings**" }-- "**sync_group_editing**" + +"**shop_ebay_sync_group_editing_listings**" }-- "**shop_ebay**" + "**shop_ebay_sync_group_listing_drafts**" }-- "**accounts**" "**shop_ebay_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" @@ -593,6 +748,12 @@ entity "**sync_groups**" { "**shop_ecwid_listings**" ||-|| "**shop_ecwid**" +"**shop_ecwid_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_ecwid_sync_group_editing_listings**" }-- "**sync_group_editing**" + +"**shop_ecwid_sync_group_editing_listings**" }-- "**shop_ecwid**" + "**shop_ecwid_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" "**shop_ecwid_sync_group_listing_drafts**" }-- "**accounts**" @@ -617,6 +778,12 @@ entity "**sync_groups**" { "**shop_etsy_listings**" ||-|| "**shop_etsy**" +"**shop_etsy_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_etsy_sync_group_editing_listings**" }-- "**sync_group_editing**" + +"**shop_etsy_sync_group_editing_listings**" }-- "**shop_etsy**" + "**shop_etsy_sync_group_listing_drafts**" }-- "**accounts**" "**shop_etsy_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" @@ -641,6 +808,12 @@ entity "**sync_groups**" { "**shop_shopify_listings**" ||-|| "**shop_shopify**" +"**shop_shopify_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_shopify_sync_group_editing_listings**" }-- "**shop_shopify**" + +"**shop_shopify_sync_group_editing_listings**" }-- "**sync_group_editing**" + "**shop_shopify_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" "**shop_shopify_sync_group_listing_drafts**" }-- "**accounts**" @@ -665,6 +838,12 @@ entity "**sync_groups**" { "**shop_square_online_listings**" ||-|| "**shop_square_online**" +"**shop_square_online_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_square_online_sync_group_editing_listings**" }-- "**shop_square_online**" + +"**shop_square_online_sync_group_editing_listings**" }-- "**sync_group_editing**" + "**shop_square_online_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" "**shop_square_online_sync_group_listing_drafts**" }-- "**accounts**" @@ -689,6 +868,12 @@ entity "**sync_groups**" { "**shop_squarespace_listings**" ||-|| "**shop_squarespace**" +"**shop_squarespace_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_squarespace_sync_group_editing_listings**" }-- "**shop_squarespace**" + +"**shop_squarespace_sync_group_editing_listings**" }-- "**sync_group_editing**" + "**shop_squarespace_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" "**shop_squarespace_sync_group_listing_drafts**" }-- "**accounts**" @@ -713,6 +898,12 @@ entity "**sync_groups**" { "**shop_tiktok_listings**" ||-|| "**shop_tiktok**" +"**shop_tiktok_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_tiktok_sync_group_editing_listings**" }-- "**sync_group_editing**" + +"**shop_tiktok_sync_group_editing_listings**" }-- "**shop_tiktok**" + "**shop_tiktok_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" "**shop_tiktok_sync_group_listing_drafts**" }-- "**accounts**" @@ -737,6 +928,12 @@ entity "**sync_groups**" { "**shop_walmart_marketplace_listings**" ||-|| "**shop_walmart_marketplace**" +"**shop_walmart_marketplace_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_walmart_marketplace_sync_group_editing_listings**" }-- "**shop_walmart_marketplace**" + +"**shop_walmart_marketplace_sync_group_editing_listings**" }-- "**sync_group_editing**" + "**shop_walmart_marketplace_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" "**shop_walmart_marketplace_sync_group_listing_drafts**" }-- "**accounts**" @@ -761,6 +958,12 @@ entity "**sync_groups**" { "**shop_wix_listings**" ||-|| "**shop_wix**" +"**shop_wix_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_wix_sync_group_editing_listings**" }-- "**sync_group_editing**" + +"**shop_wix_sync_group_editing_listings**" }-- "**shop_wix**" + "**shop_wix_sync_group_listing_drafts**" }-- "**accounts**" "**shop_wix_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" @@ -785,6 +988,12 @@ entity "**sync_groups**" { "**shop_woo_commerce_listings**" ||-|| "**shop_woo_commerce**" +"**shop_woo_commerce_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_woo_commerce_sync_group_editing_listings**" }-- "**shop_woo_commerce**" + +"**shop_woo_commerce_sync_group_editing_listings**" }-- "**sync_group_editing**" + "**shop_woo_commerce_sync_group_listing_drafts**" ||-|| "**sync_group_listing_draft_order_indexes**" "**shop_woo_commerce_sync_group_listing_drafts**" }-- "**accounts**" @@ -809,6 +1018,12 @@ entity "**sync_groups**" { "**shop_zoho_listings**" ||-|| "**shop_zoho**" +"**shop_zoho_sync_group_editing_listings**" ||-|| "**sync_group_editing_listing_order_indexes**" + +"**shop_zoho_sync_group_editing_listings**" }-- "**sync_group_editing**" + +"**shop_zoho_sync_group_editing_listings**" }-- "**shop_zoho**" + "**shop_zoho_sync_group_listing_drafts**" }-- "**accounts**" "**shop_zoho_sync_group_listing_drafts**" }-- "**sync_group_listing_draft_order_indexes**" @@ -823,6 +1038,16 @@ entity "**sync_groups**" { "**shop_zoho_sync_group_listings**" }-- "**sync_group_listing_order_indexes**" +"**sync_group_drafts**" ||-|| "**accounts**" + +"**sync_group_drafts**" }-- "**sync_groups**" + +"**sync_group_editing**" ||-|| "**accounts**" + +"**sync_group_editing**" }-- "**sync_groups**" + +"**sync_group_editing_listing_order_indexes**" }-- "**sync_group_editing**" + "**sync_group_listing_draft_order_indexes**" }-- "**accounts**" "**sync_group_listing_order_indexes**" }-- "**sync_groups**" diff --git a/domains/accounts/accounts.go b/domains/accounts/accounts.go index 16011f6..fe69ad7 100644 --- a/domains/accounts/accounts.go +++ b/domains/accounts/accounts.go @@ -7,6 +7,8 @@ import ( "context" "errors" "fmt" + "slices" + "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -45,6 +47,11 @@ type ( Description string Count int64 } + + ListingWithOrderIndex struct { + Listing + OrderIndex int + } ) func NewStore(logger *logging.Logger, db *pgxpool.Pool) *Store { @@ -648,6 +655,212 @@ func (db *Store) StartEditingMockSyncGroup(ctx context.Context, acctID, syncGrou return v.Int64, v.Valid, nil } +func (db *Store) SaveMockSyncGroupBeingEdited(ctx context.Context, acctID int64) (syncGroupID int64, err error) { + err = db.beginTxn(ctx, func(tx pgx.Tx) error { + // delete old sync group listings + + rows, err := tx.Query( + ctx, + ` + WITH the_sync_group AS ( + SELECT + sync_group_id + FROM + mock.sync_group_editing + WHERE + account_id = @account_id + ), deleted_indexes AS ( + DELETE FROM + mock.sync_group_listing_order_indexes ind + USING + mock.sync_group_editing AS ed + WHERE + ed.account_id = @account_id + AND ind.sync_group_id = ed.sync_group_id + RETURNING + sync_group_id + ) + SELECT + sync_group_id + FROM + deleted_indexes + LIMIT + 1 + `, + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return fmt.Errorf("failed to query editing sync group: %w", err) + } + + if syncGroupID, err = pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64]); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return consts.ErrNotFound + } + return fmt.Errorf("failed to scan row: %w", err) + } + + var listingCount int + for _, in := range getAllMockShopSchemaInfos() { + // look up edited sync group listings + + rows, err := tx.Query( + ctx, + fmt.Sprintf( + ` + SELECT + order_index, + shop_id, + listing_id + FROM + %s el + WHERE + account_id = @account_id + `, + in.syncGroupEditingListingTable, + ), + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + + vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct { + Order_index int + Shop_id pgtype.Text + Listing_id pgtype.Text + }]) + if err != nil { + return fmt.Errorf("failed to scan rows: %w", err) + } + + if len(vs) == 0 { + continue + } + + // fail if any shop_id's or listing_id's are NULL + for _, v := range vs { + if !v.Shop_id.Valid { + return fmt.Errorf("%w: shop id missing on listing", consts.ErrConflict) + } + if !v.Listing_id.Valid { + return fmt.Errorf("%w: listing id missing on listing", consts.ErrConflict) + } + } + + // create new sync group listing order indexes + + values := make([]string, len(vs)) + args := make(pgx.NamedArgs, len(vs)) + for i, v := range vs { + values[i] = fmt.Sprintf("(@sync_group_id_%[1]d, @order_index_%[1]d)", i) + args[fmt.Sprintf("sync_group_id_%d", i)] = syncGroupID + args[fmt.Sprintf("order_index_%d", i)] = v.Order_index + } + listingCount += len(vs) + + _, err = tx.Exec( + ctx, + fmt.Sprintf( + ` + INSERT INTO + mock.sync_group_listing_order_indexes ( + sync_group_id, + order_index + ) + VALUES + %s + `, + strings.Join(values, ", "), + ), + args, + ) + if err != nil { + return fmt.Errorf("failed to delete rows for sync group listing order indexes: %w", err) + } + + // create new sync group listings + + values = make([]string, len(vs)) + args = make(pgx.NamedArgs, len(vs)) + for i, v := range vs { + values[i] = fmt.Sprintf("(@account_id_%[1]d, @sync_group_id_%[1]d, @shop_id_%[1]d, @listing_id_%[1]d, @order_index_%[1]d)", i) + args[fmt.Sprintf("account_id_%d", i)] = acctID + args[fmt.Sprintf("sync_group_id_%d", i)] = syncGroupID + args[fmt.Sprintf("shop_id_%d", i)] = v.Shop_id + args[fmt.Sprintf("listing_id_%d", i)] = v.Listing_id + args[fmt.Sprintf("order_index_%d", i)] = v.Order_index + } + + _, err = tx.Exec( + ctx, + fmt.Sprintf( + ` + INSERT INTO + %s ( + account_id, + sync_group_id, + shop_id, + listing_id, + order_index + ) + VALUES + %s + `, + in.syncGroupListingsTable, + strings.Join(values, ", "), + ), + args, + ) + if err != nil { + return fmt.Errorf("failed to perform query inserting new sync group listings: %w", err) + } + } + + // there must be at least two listings per sync group + + if listingCount < 2 { + return fmt.Errorf("%w: invalid number of listings for sync group: %d: minimum of two required", consts.ErrConflict, listingCount) + } + + // delete the sync group editing record + // this will cascade the editing listing records + + rows, err = tx.Query( + ctx, + ` + DELETE FROM + mock.sync_group_editing + WHERE + account_id = @account_id + RETURNING + sync_group_id + `, + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query deleting editing sync group: %w", err) + } + + if syncGroupID, err = pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64]); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return consts.ErrNotFound + } + return fmt.Errorf("failed to scan row: %w", err) + } + + return nil + }) + + return syncGroupID, err +} + func (db *Store) CancelEditingOfMockSyncGroupForAccount(ctx context.Context, acctID int64) (prevSyncGroupID int64, prevSyncGroupExists bool, err error) { rows, err := db.db.Query( ctx, @@ -678,6 +891,156 @@ func (db *Store) CancelEditingOfMockSyncGroupForAccount(ctx context.Context, acc return prevSyncGroupID, true, nil } +func (db *Store) GetMockSyncGroupEditingListings(ctx context.Context, acctID int64) ([]ListingWithOrderIndex, error) { + var ( + listings []ListingWithOrderIndex + allOrderIndexes []int + ) + //orderIndexPerListing := make(map[Platform]map[string]int) + + err := db.beginReadonlyTxn(ctx, func(tx pgx.Tx) error { + // get the mock sync group editing listings from each table + + for _, in := range getAllMockShopSchemaInfos() { + rows, err := tx.Query( + ctx, + fmt.Sprintf( + ` + SELECT + eli.shop_id, + eli.listing_id, + eli.order_index, + li.name, + li.count, + li.sku, + li.description, + sh.name as shop_name + FROM + %s AS eli + LEFT JOIN + %s AS li + USING + (account_id, shop_id, listing_id) + JOIN + %s AS sh + USING + (account_id, shop_id) + WHERE + account_id = @account_id + `, + in.syncGroupEditingListingTable, + in.listingsTable, + in.shopTable, + ), + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query to get mock sync group editing listings: %w", err) + } + + vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct { + Shop_id string + Listing_id pgtype.Text + Order_index int + Name pgtype.Text + Count pgtype.Int8 + Sku pgtype.Text + Description pgtype.Text + Shop_name string + }]) + if err != nil { + return fmt.Errorf("failed to scan rows: %w", err) + } + + for _, v := range vs { + listings = append(listings, ListingWithOrderIndex{ + Listing: Listing{ + AccountShopListingIDs: NewAccountIDs(acctID). + ShopID(in.platform, v.Shop_id). + ListingID(v.Listing_id.String), + ShopName: v.Shop_name, + SKU: v.Sku.String, + Name: v.Name.String, + Description: v.Description.String, + Count: v.Count.Int64, + }, + OrderIndex: v.Order_index, + }) + } + } + + // loading the empty indexes, too + rows, err := tx.Query( + ctx, + ` + SELECT + order_index + FROM + mock.sync_group_editing_listing_order_indexes + WHERE + account_id = @account_id + `, + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + + if allOrderIndexes, err = pgx.CollectRows(rows, pgx.RowTo[int]); err != nil { + return fmt.Errorf("failed to scan rows: %w", err) + } + + return nil + }) + + slices.SortFunc(listings, func(a, b ListingWithOrderIndex) int { + return a.OrderIndex - b.OrderIndex + }) + + indexPerOrderIndex := make(map[int]int) + for i, v := range listings { + indexPerOrderIndex[v.OrderIndex] = i + } + + for _, i := range allOrderIndexes { + if _, ok := indexPerOrderIndex[i]; ok { + continue + } + + for j := i - 1; j >= 0; j -= 1 { + index, ok := indexPerOrderIndex[j] + if !ok { + continue + } + + // insert the new empty listing into the list + + listings = slices.Insert(listings, index+1, ListingWithOrderIndex{ + Listing: Listing{ + AccountShopListingIDs: NewAccountIDs(acctID). + ShopID("", ""). + ListingID(""), + }, + OrderIndex: i, + }) + + indexPerOrderIndex[i] = index + 1 + for k, v := range indexPerOrderIndex { + if k > i { + indexPerOrderIndex[k] = v + 1 + } + } + break + } + } + + return listings, err +} + func (db *Store) MockSyncGroupIsBeingEdited(ctx context.Context, acctID, syncGroupID int64) (bool, error) { rows, err := db.db.Query( ctx, @@ -709,6 +1072,395 @@ func (db *Store) MockSyncGroupIsBeingEdited(ctx context.Context, acctID, syncGro return true, nil } +func (db *Store) GetMockSyncGroupEditingListing(ctx context.Context, acctID, syncGroupID, orderIndex int64) (*Listing, error) { + var listing *Listing + + err := db.beginReadonlyTxn(ctx, func(tx pgx.Tx) error { + // assert the sync group is being edited + + rows, err := tx.Query( + ctx, + ` + SELECT + true + FROM + mock.sync_group_editing + WHERE + account_id = @account_id + AND sync_group_id = @sync_group_id + `, + pgx.NamedArgs{ + "account_id": acctID, + "sync_group_id": syncGroupID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + + if _, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[bool]); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return consts.ErrNotFound + } + return fmt.Errorf("failed to scan rows: %w", err) + } + + // find the editing listing + + for _, in := range getAllMockShopSchemaInfos() { + rows, err := tx.Query( + ctx, + fmt.Sprintf( + ` + SELECT + eli.shop_id, + eli.listing_id, + eli.order_index, + li.name, + li.count, + li.sku, + li.description, + sh.name as shop_name + FROM + %s AS eli + JOIN + %s AS li + USING + (account_id, shop_id, listing_id) + JOIN + %s AS sh + USING + (account_id, shop_id) + WHERE + account_id = @account_id + AND eli.order_index = @order_index + `, + in.syncGroupEditingListingTable, + in.listingsTable, + in.shopTable, + ), + pgx.NamedArgs{ + "account_id": acctID, + "order_index": orderIndex, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query to get mock sync group editing listings: %w", err) + } + + v, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[struct { + Shop_id string + Listing_id string + Order_index int + Name string + Count int64 + Sku string + Description string + Shop_name string + }]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + continue + } + return fmt.Errorf("failed to scan rows: %w", err) + } + + listing = &Listing{ + AccountShopListingIDs: NewAccountIDs(acctID). + ShopID(in.platform, v.Shop_id). + ListingID(v.Listing_id), + ShopName: v.Shop_name, + SKU: v.Sku, + Name: v.Name, + Description: v.Description, + Count: v.Count, + } + return nil + } + + return consts.ErrNotFound + }) + + return listing, err +} + +func (db *Store) AddListingToMockSyncGroupBeingEdited(ctx context.Context, acctID int64) (syncGroupID int64, orderIndex int, err error) { + err = db.beginTxn(ctx, func(tx pgx.Tx) error { + // assert the sync group is being edited + + rows, err := tx.Query( + ctx, + ` + SELECT + sync_group_id + FROM + mock.sync_group_editing + WHERE + account_id = @account_id + `, + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + + syncGroupID, err = pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return consts.ErrNotFound + } + return fmt.Errorf("failed to scan rows: %w", err) + } + + rows, err = tx.Query( + ctx, + ` + WITH new_order_index AS ( + SELECT + COALESCE(MAX(order_index)+1, 0) AS order_index + FROM + mock.sync_group_editing_listing_order_indexes + ) + INSERT INTO + mock.sync_group_editing_listing_order_indexes ( + account_id, + order_index + ) + SELECT + @account_id, + new_order_index.order_index + FROM + new_order_index + RETURNING + order_index + `, + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return fmt.Errorf("failed to insert new order index row: %w", err) + } + + orderIndex, err = pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]) + if err != nil { + return fmt.Errorf("failed to scan rows: %w", err) + } + + return nil + }) + return syncGroupID, orderIndex, err +} + +func (db *Store) SetShopInListingInMockSyncGroupBeingEdited(ctx context.Context, acctID, syncGroupID int64, orderIndex int, platform Platform, shopID string) error { + in, ok := getMockShopSchemaInfo(platform) + if !ok { + return consts.ErrNotFound + } + + return db.beginTxn(ctx, func(tx pgx.Tx) error { + // assert the sync group is being edited + + rows, err := tx.Query( + ctx, + ` + SELECT + true + FROM + mock.sync_group_editing + WHERE + account_id = @account_id + AND sync_group_id = @sync_group_id + `, + pgx.NamedArgs{ + "account_id": acctID, + "sync_group_id": syncGroupID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + + if _, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[bool]); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return consts.ErrNotFound + } + return fmt.Errorf("failed to scan rows: %w", err) + } + + // delete old listing: this will delete the store-specific listing row implicitly without the lookup + + tags, err := tx.Exec( + ctx, + ` + DELETE FROM + mock.sync_group_editing_listing_order_indexes + WHERE + account_id = @account_id + AND order_index = @order_index + `, + pgx.NamedArgs{ + "account_id": acctID, + "order_index": orderIndex, + }, + ) + if err != nil { + return fmt.Errorf("failed to deleted order index record: %w", err) + } + if tags.RowsAffected() == 0 { + return consts.ErrNotFound + } + + // create new listing + + _, err = tx.Exec( + ctx, + fmt.Sprintf( + ` + WITH new_order_index_row(account_id, order_index) AS ( + INSERT INTO + mock.sync_group_editing_listing_order_indexes ( + account_id, + order_index + ) + VALUES ( + @account_id, + @order_index + ) + RETURNING + account_id, + order_index + ) + INSERT INTO + %s AS eli ( + account_id, + order_index, + shop_id + ) + SELECT + account_id, + order_index, + @shop_id as shop_id + FROM + new_order_index_row + `, + in.syncGroupEditingListingTable, + ), + pgx.NamedArgs{ + "account_id": acctID, + "order_index": orderIndex, + "shop_id": shopID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query to insert mock sync group editing listings: %w", err) + } + + return nil + }) +} + +func (db *Store) SetListingInListingInMockSyncGroupBeingEdited(ctx context.Context, acctID, syncGroupID int64, orderIndex int, listingID string) error { + return db.beginTxn(ctx, func(tx pgx.Tx) error { + // assert the sync group is being edited + + rows, err := tx.Query( + ctx, + ` + SELECT + true + FROM + mock.sync_group_editing + WHERE + account_id = @account_id + AND sync_group_id = @sync_group_id + `, + pgx.NamedArgs{ + "account_id": acctID, + "sync_group_id": syncGroupID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + + if _, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[bool]); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return consts.ErrNotFound + } + return fmt.Errorf("failed to scan rows: %w", err) + } + + // update the editing listing + + for _, in := range getAllMockShopSchemaInfos() { + tag, err := tx.Exec( + ctx, + fmt.Sprintf( + ` + UPDATE + %s AS eli + SET + listing_id = @listing_id + WHERE + account_id = @account_id + AND order_index = @order_index + `, + in.syncGroupEditingListingTable, + ), + pgx.NamedArgs{ + "account_id": acctID, + "order_index": orderIndex, + "listing_id": listingID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query to update mock sync group editing listings: %w", err) + } + + switch n := tag.RowsAffected(); n { + case 0: + continue + case 1: + return nil + default: + return fmt.Errorf("unexpected number of rows affected: %d", n) + } + + return nil + } + + return consts.ErrNotFound + }) +} + +func (db *Store) DeleteListingInListingInMockSyncGroupBeingEdited(ctx context.Context, acctID int64, orderIndex int) error { + tag, err := db.db.Exec( + ctx, + ` + DELETE FROM + mock.sync_group_editing_listing_order_indexes + WHERE + account_id = @account_id + AND order_index = @order_index + `, + pgx.NamedArgs{ + "account_id": acctID, + "order_index": orderIndex, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + if tag.RowsAffected() == 0 { + return consts.ErrNotFound + } + + return nil +} + // additional context func (db *Store) GetAccountPointerByUserID(ctx context.Context, userID string) (*Account, error) { @@ -721,3 +1473,11 @@ func (db *Store) GetAccountPointerByUserID(ctx context.Context, userID string) ( } return nil, err } + +func (db *Store) beginReadonlyTxn(ctx context.Context, cb func(pgx.Tx) error) error { + return pgx.BeginTxFunc(ctx, db.db, pgx.TxOptions{AccessMode: pgx.ReadOnly}, cb) +} + +func (db *Store) beginTxn(ctx context.Context, cb func(pgx.Tx) error) error { + return pgx.BeginFunc(ctx, db.db, cb) +} diff --git a/domains/accounts/store_with_context.go b/domains/accounts/store_with_context.go index 984ad8f..fdb42d4 100644 --- a/domains/accounts/store_with_context.go +++ b/domains/accounts/store_with_context.go @@ -69,14 +69,34 @@ func (v_ctx *StoreWithContext) StartEditingMockSyncGroup(acctID int64, syncGroup return v_ctx.Store.StartEditingMockSyncGroup(v_ctx.ctx, acctID, syncGroupID) } +func (v_ctx *StoreWithContext) CancelEditingOfMockSyncGroupForAccount(acctID int64) (int64, bool, error) { + return v_ctx.Store.CancelEditingOfMockSyncGroupForAccount(v_ctx.ctx, acctID) +} + +func (v_ctx *StoreWithContext) GetMockSyncGroupEditingListings(acctID int64) ([]ListingWithOrderIndex, error) { + return v_ctx.Store.GetMockSyncGroupEditingListings(v_ctx.ctx, acctID) +} + func (v_ctx *StoreWithContext) MockSyncGroupIsBeingEdited(acctID int64, syncGroupID int64) (bool, error) { return v_ctx.Store.MockSyncGroupIsBeingEdited(v_ctx.ctx, acctID, syncGroupID) } +func (v_ctx *StoreWithContext) GetMockSyncGroupEditingListing(acctID int64, syncGroupID int64, orderIndex int64) (*Listing, error) { + return v_ctx.Store.GetMockSyncGroupEditingListing(v_ctx.ctx, acctID, syncGroupID, orderIndex) +} + +func (v_ctx *StoreWithContext) SetShopInListingInMockSyncGroupBeingEdited(acctID int64, syncGroupID int64, orderIndex int, platform Platform, shopID string) error { + return v_ctx.Store.SetShopInListingInMockSyncGroupBeingEdited(v_ctx.ctx, acctID, syncGroupID, orderIndex, platform, shopID) +} + func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) { return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID) } +func (v_ctx *StoreWithContext) beginReadonlyTxn(cb func(pgx.Tx) error) error { + return v_ctx.Store.beginReadonlyTxn(v_ctx.ctx, cb) +} + func (v_ctx *StoreWithContext) CreateMockShop(acctID int64, platform Platform, name string) (uuid.UUID, error) { return v_ctx.Store.CreateMockShop(v_ctx.ctx, acctID, platform, name) } diff --git a/server/api/accounts/router.go b/server/api/accounts/router.go index d43a12c..9b55897 100644 --- a/server/api/accounts/router.go +++ b/server/api/accounts/router.go @@ -58,8 +58,17 @@ func Routes( mockSyncGroups := syncGroups.Group("/mock") mockSyncGroups.POST("", pub.Publish("/:acctID/inventory/sync-groups/mock"), response.Handler(as.saveNewMockSyncGroup)) mockSyncGroups.DELETE("/:syncGroupID", pub.Publish("/:acctID/inventory/sync-groups/mock"), response.Handler(as.deleteMockSyncGroup)) - mockSyncGroups.PUT("/editing", pub.Publish("/:acctID/inventory/sync-groups/mock/editing"), response.Handler(as.selectMockSyncGroupForEditing)) - mockSyncGroups.DELETE("/editing", pub.Publish("/:acctID/inventory/sync-groups/mock/editing"), response.Handler(as.cancelMockSyncGroupEdits)) + + setMockSyncGroupForEditingEndpoints := mockSyncGroups.Group("/editing", pub.Publish("/:acctID/inventory/sync-groups/mock/editing")) + setMockSyncGroupForEditingEndpoints.PUT("", response.Handler(as.selectMockSyncGroupForEditing)) + setMockSyncGroupForEditingEndpoints.POST("", response.Handler(as.saveMockSyncGroupBeingEdited)) + setMockSyncGroupForEditingEndpoints.DELETE("", response.Handler(as.cancelMockSyncGroupEdits)) + setMockSyncGroupForEditingEndpoints.POST("/listings", response.Handler(as.addListingToMockSyncGroupBeingEdited)) + + mockSyncGroupBeingEdited := mockSyncGroups.Group("/:syncGroupID/editing", pub.Publish("/:acctID/inventory/sync-groups/mock/:syncGroupID/editing")) + mockSyncGroupBeingEdited.PUT("/listings/:orderIndex/shop", response.Handler(as.setShopInListingInMockSyncGroupBeingEdited)) + mockSyncGroupBeingEdited.PUT("/listings/:orderIndex/listing", response.Handler(as.setListingInListingInMockSyncGroupBeingEdited)) + mockSyncGroupBeingEdited.DELETE("/listings/:orderIndex", response.Handler(as.deleteListingInListingInMockSyncGroupBeingEdited)) mockDraftListings := mockSyncGroups.Group("/draft/listings", pub.Publish("/:acctID/inventory/sync-groups/mock/draft/listings")) mockDraftListings.POST("", response.Handler(as.createMockSyncGroupListingDraft)) @@ -571,6 +580,31 @@ func (s *accountSubrouter) selectMockSyncGroupForEditing(c *gin.Context) (respon return response.StatusOK(), nil } +// POST /:acctID/inventory/sync-groups/mock/editing +func (s *accountSubrouter) saveMockSyncGroupBeingEdited(c *gin.Context) (response.Response, error) { + r := c.Request + ctx := r.Context() + acctID := auth.GetIdentity(ctx).Account.AccountID + + syncGroupID, err := s.accts.SaveMockSyncGroupBeingEdited(ctx, acctID) + if err != nil { + return nil, response.Errorf("failed to save mock sync group being edited: %w", response.ErrorFromConstant(err)) + } + + // emit events indicating sync groups being edited and no longer being edited + + if err := s.pub.Push( + c, + acctID, + fmt.Sprintf("accounts_%d_inventory_sync-groups_mock_%d", acctID, syncGroupID), + fmt.Sprintf("accounts_%d_inventory_sync-groups_mock_%d_editing", acctID, syncGroupID), + ); err != nil { + s.log.Errorf("failed to publish mock sync group editing events: %v", err) + } + + return response.StatusOK(), nil +} + // DELETE /:acctID/inventory/sync-groups/mock/editing func (s *accountSubrouter) cancelMockSyncGroupEdits(c *gin.Context) (response.Response, error) { r := c.Request @@ -593,6 +627,89 @@ func (s *accountSubrouter) cancelMockSyncGroupEdits(c *gin.Context) (response.Re return response.StatusOK(), nil } +// POST /:acctID/inventory/sync-groups/mock/editing/listings +func (s *accountSubrouter) addListingToMockSyncGroupBeingEdited(c *gin.Context) (response.Response, error) { + r := c.Request + ctx := r.Context() + acctID := auth.GetIdentity(ctx).Account.AccountID + + if _, _, err := s.accts.AddListingToMockSyncGroupBeingEdited(ctx, acctID); err != nil { + return nil, response.Errorf("failed to add listing to mock sync group being edited: %w", response.ErrorFromConstant(err)) + } + + return response.StatusCreated(), nil +} + +// PUT /:acctID/inventory/sync-groups/mock/editing/listings/:orderIndex/shop +func (s *accountSubrouter) setShopInListingInMockSyncGroupBeingEdited(c *gin.Context) (response.Response, error) { + acctID := auth.GetIdentity(c).Account.AccountID + + var ( + syncGroupID int64 + orderIndex int + platform accounts.Platform + shopID string + ) + + if err := param.Path("syncGroupID", param.Int64(&syncGroupID)). + Path("orderIndex", param.Int(&orderIndex)). + Form("platform", param.Platform(&platform)). + Form("shop-id", param.Text(&shopID)). + Unmarshal(c); err != nil { + return nil, err + } + + if err := s.accts.SetShopInListingInMockSyncGroupBeingEdited(c, acctID, syncGroupID, orderIndex, platform, shopID); err != nil { + return nil, response.Errorf("failed to set shop in listing in mock sync group being edited: %w", response.ErrorFromConstant(err)) + } + + return response.StatusOK(), nil +} + +// PUT /:acctID/inventory/sync-groups/mock/editing/listings/:orderIndex/listing +func (s *accountSubrouter) setListingInListingInMockSyncGroupBeingEdited(c *gin.Context) (response.Response, error) { + acctID := auth.GetIdentity(c).Account.AccountID + + var ( + syncGroupID int64 + orderIndex int + listingID string + ) + + if err := param.Path("syncGroupID", param.Int64(&syncGroupID)). + Path("orderIndex", param.Int(&orderIndex)). + Form("listing-id", param.Text(&listingID)). + Unmarshal(c); err != nil { + return nil, err + } + + if err := s.accts.SetListingInListingInMockSyncGroupBeingEdited(c, acctID, syncGroupID, orderIndex, listingID); err != nil { + return nil, response.Errorf("failed to set listing in listing in mock sync group being edited: %w", response.ErrorFromConstant(err)) + } + + return response.StatusOK(), nil +} + +// DELETE /:acctID/inventory/sync-groups/mock/editing/listings/:orderIndex +func (s *accountSubrouter) deleteListingInListingInMockSyncGroupBeingEdited(c *gin.Context) (response.Response, error) { + acctID := auth.GetIdentity(c).Account.AccountID + + var ( + orderIndex int + ) + + if err := param.Path("orderIndex", param.Int(&orderIndex)). + Unmarshal(c); err != nil { + return nil, err + } + + if err := s.accts.DeleteListingInListingInMockSyncGroupBeingEdited(c, acctID, orderIndex); err != nil { + return nil, response.Errorf("failed to delete listing in mock sync group being edited: %w", response.ErrorFromConstant(err)) + } + + return response.StatusOK(), nil +} + func lowerSnakeCase(s accounts.Platform) string { return strings.ToLower(strings.Join(strings.Split(string(s), " "), "_")) } diff --git a/styles/index.css b/styles/index.css index 08a19fc..1319e16 100644 --- a/styles/index.css +++ b/styles/index.css @@ -197,29 +197,6 @@ .bottom-0 { bottom: calc(var(--spacing) * 0); } - .grid-table-\[1fr_1fr_1fr_1fr_1fr_1fr\] { - &:is(table) { - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; - & > :is(thead, tbody, tfoot) { - grid-column-start: 1; - grid-column-end: -1; - display: grid; - grid-template-columns: subgrid; - & > tr { - grid-column-start: 1; - grid-column-end: -1; - display: grid; - grid-template-columns: subgrid; - & > th, & > td { - grid-column: span 1; - align-content: center; - text-align: center; - } - } - } - } - } .grid-table-\[1fr_1fr_1fr_1fr_1fr_1fr_1fr\] { &:is(table) { display: grid; diff --git a/templates/components/accounts/{acctID.int64}/inventory/sync-groups/mock/draft/listings/{orderIndex.int}.html.tmpl b/templates/components/accounts/{acctID.int64}/inventory/sync-groups/mock/draft/listings/{orderIndex.int}.html.tmpl index f44b508..4c8f29a 100644 --- a/templates/components/accounts/{acctID.int64}/inventory/sync-groups/mock/draft/listings/{orderIndex.int}.html.tmpl +++ b/templates/components/accounts/{acctID.int64}/inventory/sync-groups/mock/draft/listings/{orderIndex.int}.html.tmpl @@ -29,13 +29,12 @@ on setListing(listing) call setFilledOut(true) - on resetListing call setFilledOut(false) " >