diff --git a/database_migrations/000017_account_page_shops_order.down.sql b/database_migrations/000017_account_page_shops_order.down.sql new file mode 100644 index 0000000..db2c056 --- /dev/null +++ b/database_migrations/000017_account_page_shops_order.down.sql @@ -0,0 +1 @@ +DROP TABLE accounts_page_platform_order_indexes; diff --git a/database_migrations/000017_account_page_shops_order.up.sql b/database_migrations/000017_account_page_shops_order.up.sql new file mode 100644 index 0000000..2d65a8f --- /dev/null +++ b/database_migrations/000017_account_page_shops_order.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE accounts_page_platform_order_indexes ( + account_id INTEGER NOT NULL, + platform platform NOT NULL, + order_index SMALLINT NOT NULL CHECK (order_index >= 0), + + PRIMARY KEY (account_id, platform), + UNIQUE (account_id, order_index) +); diff --git a/diagrams/database_schema_public.svg b/diagrams/database_schema_public.svg index 7f5ee97..bffc4c1 100644 --- a/diagrams/database_schema_public.svg +++ b/diagrams/database_schema_public.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/diagrams/database_schema_public.uml b/diagrams/database_schema_public.uml index 0f7d65b..69f4700 100644 --- a/diagrams/database_schema_public.uml +++ b/diagrams/database_schema_public.uml @@ -11,6 +11,14 @@ entity "**accounts**" { *""user_id"": //text [FK]// } +entity "**accounts_page_platform_order_indexes**" { + + ""account_id"": //integer [PK]// + + ""platform"": //platform [PK]// + -- + *""account_id"": //integer // + *""order_index"": //smallint // +} + entity "**etsy_access_tokens**" { + ""user_id"": //integer [PK][FK]// -- diff --git a/internal/domains/accounts/page.go b/internal/domains/accounts/page.go new file mode 100644 index 0000000..b4ca13c --- /dev/null +++ b/internal/domains/accounts/page.go @@ -0,0 +1,273 @@ +package accounts + +import ( + "context" + "errors" + "fmt" + "ruben/inventory2/internal/consts" + "slices" + "strings" + + "github.com/jackc/pgx/v5" +) + +// TODO: simpify, if possible (single query ideal) +func (db *Store) SetOrderOfPlatformOnAccountPage(ctx context.Context, acctID int64, platform Platform, orderIndex int) error { + tx, err := db.db.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer tx.Rollback(ctx) + + // look up specified indexes + + rows, err := tx.Query( + ctx, + ` + SELECT + platform, + order_index + FROM + accounts_page_platform_order_indexes + WHERE + account_id = @account_id + ORDER BY + order_index ASC + `, + pgx.NamedArgs{ + "account_id": acctID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query to look up existing indexes: %w", err) + } + + indexes, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct { + Platform Platform + Order_index int + }]) + if err != nil { + return fmt.Errorf("failed to scan rows for query to look up existing indexes: %w", err) + } + + // compute the implied indexes, + // and construct the full sequence of platforms + + indexPerPlatform := make(map[Platform]int, len(allPlatforms)) + platformPerIndex := make(map[int]Platform, len(allPlatforms)) + for _, v := range indexes { + indexPerPlatform[v.Platform] = v.Order_index + platformPerIndex[v.Order_index] = v.Platform + } + + prevIndex := -1 + for _, p := range allPlatforms { + if _, indexSet := indexPerPlatform[p]; !indexSet { + index := prevIndex + for indexUsed := true; indexUsed; _, indexUsed = platformPerIndex[index] { + index += 1 + } + prevIndex = index + + indexPerPlatform[p] = index + platformPerIndex[index] = p + } + } + + prevOrderIndex := indexPerPlatform[platform] + if indexPerPlatform[platform] == orderIndex { + return nil + } + + if increased := orderIndex > prevOrderIndex; increased { + // decrement in indexes between the previous index and new index + for index := prevOrderIndex + 1; index <= orderIndex; index += 1 { + p := platformPerIndex[index] + indexPerPlatform[p] = index - 1 + platformPerIndex[index-1] = p + } + indexPerPlatform[platform] = orderIndex + platformPerIndex[orderIndex] = platform + } else { + // increment in indexes between the previous index and new index + for index := prevOrderIndex - 1; index >= orderIndex; index -= 1 { + p := platformPerIndex[index] + indexPerPlatform[p] = index + 1 + platformPerIndex[index+1] = p + } + indexPerPlatform[platform] = orderIndex + platformPerIndex[orderIndex] = platform + } + + // delete all indexes for the acct in the db, + // then insert all updated indexes + + valuesLines := make([]string, len(allPlatforms)) + args := pgx.NamedArgs{ + "account_id": acctID, + } + for i, p := range allPlatforms { + valuesLines[i] = fmt.Sprintf("(@account_id, @platform_%d, @order_index_%d::smallint)", i, i) + args[fmt.Sprintf("platform_%d", i)] = p + args[fmt.Sprintf("order_index_%d", i)] = indexPerPlatform[p] + } + + _, err = tx.Exec( + ctx, + fmt.Sprintf(` + WITH deleted_indexes AS ( + DELETE FROM + accounts_page_platform_order_indexes + WHERE + account_id = @account_id + RETURNING + account_id + ), new_indexes(account_id, platform, order_index) AS ( + SELECT DISTINCT + x.account_id, x.platform, x.order_index + FROM + (VALUES %s) AS x(account_id, platform, order_index) + LEFT JOIN + deleted_indexes + ON + x.account_id = deleted_indexes.account_id + ) + INSERT INTO + accounts_page_platform_order_indexes ( + account_id, + platform, + order_index + ) + SELECT + account_id, + platform::Platform, + order_index + FROM + new_indexes + `, strings.Join(valuesLines, ", ")), + args, + ) + if err != nil { + return fmt.Errorf("failed to perform query to delete old indexes and insert new indexes: %w", err) + } + + if tx.Commit(ctx); err != nil { + return fmt.Errorf("failed to commit txn: %w", err) + } + + return nil +} + +func (db *Store) GetOrderOfPlatformsOnAccountPage(ctx context.Context, acctID int64) ([]Platform, error) { + // get specified order indexes per platform + rows, err := db.db.Query( + ctx, + ` + SELECT + platform, + order_index + FROM + accounts_page_platform_order_indexes + 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 { + Platform Platform + Order_index int + } + + platformsWithIndexes, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct { + Platform Platform + Order_index int + }]) + if err != nil { + return nil, fmt.Errorf("failed to scan rows: %w", err) + } + + // hash the specified platforms by index + + platformsByIndex := make(map[int]Platform, len(platformsWithIndexes)) + for _, v := range platformsWithIndexes { + platformsByIndex[v.Order_index] = v.Platform + } + + // form a sorted list of platforms without indexes specified, sorted alphabetically + + allPlatformsByName := make(map[Platform]struct{}, len(allPlatforms)) + for _, p := range allPlatforms { + allPlatformsByName[p] = struct{}{} + } + for _, v := range platformsWithIndexes { + delete(allPlatformsByName, v.Platform) + } + platformsWithoutAnIndexSpecified := make([]Platform, 0, len(allPlatformsByName)) + for p := range allPlatformsByName { + platformsWithoutAnIndexSpecified = append(platformsWithoutAnIndexSpecified, p) + } + slices.SortFunc(platformsWithoutAnIndexSpecified, func(a, b Platform) int { + al := strings.ToLower(string(a)) + bl := strings.ToLower(string(b)) + if al < bl { + return -1 + } + if bl < al { + return 1 + } + return 0 + }) + + // construct the list of the ordered platforms + + res := make([]Platform, len(allPlatforms)) + nextIndex := 0 + for i := range res { + if p, ok := platformsByIndex[i]; ok { + res[i] = p + } else { + res[i] = platformsWithoutAnIndexSpecified[nextIndex] + nextIndex += 1 + } + } + + return res, nil +} + +func (db *Store) GetOrderOfPlatformOnAccountPage(ctx context.Context, acctID int64, platform Platform) (int, error) { + rows, err := db.db.Query( + ctx, + ` + SELECT + order_index + FROM + accounts_page_platform_order_indexes + WHERE + account_id = @account_id + AND platform = @platform + `, + pgx.NamedArgs{ + "account_id": acctID, + "platform": platform, + }, + ) + if err != nil { + return 0, fmt.Errorf("failed to perform query: %w", err) + } + + i, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, consts.ErrNotFound + } + return 0, fmt.Errorf("failed to scan rows: %w", err) + } + + return i, nil +} diff --git a/internal/domains/accounts/platform.go b/internal/domains/accounts/platform.go index 629e911..5f093a1 100644 --- a/internal/domains/accounts/platform.go +++ b/internal/domains/accounts/platform.go @@ -10,41 +10,41 @@ type ( ) const ( - Etsy Platform = "Etsy" - Tiktok Platform = "Tiktok" - Wix Platform = "Wix" - Ebay Platform = "ebay" - WalmartMarketplace Platform = "walmart_marketplace" Amazon Platform = "amazon" BigCartel Platform = "big_cartel" + Ebay Platform = "ebay" Ecwid Platform = "ecwid" - Zoho Platform = "zoho" + Etsy Platform = "Etsy" + Shopify Platform = "shopify" SquareOnline Platform = "square_online" Squarespace Platform = "squarespace" + Tiktok Platform = "Tiktok" + WalmartMarketplace Platform = "walmart_marketplace" + Wix Platform = "Wix" WooCommerce Platform = "woo_commerce" - Shopify Platform = "shopify" + Zoho Platform = "zoho" ) var ( - allValues = []Platform{ - Etsy, - Tiktok, - Wix, - Ebay, - WalmartMarketplace, + allPlatforms = []Platform{ Amazon, BigCartel, + Ebay, Ecwid, - Zoho, + Etsy, + Shopify, SquareOnline, Squarespace, + Tiktok, + WalmartMarketplace, + Wix, WooCommerce, - Shopify, + Zoho, } ) func NewPlatform(s string) (Platform, error) { - for _, v := range allValues { + for _, v := range allPlatforms { if strings.ToLower(s) == strings.ToLower(string(v)) { return v, nil } @@ -52,6 +52,38 @@ func NewPlatform(s string) (Platform, error) { return "", fmt.Errorf("unrecognized constant: %q", s) } +func (p Platform) PrettyPrint() string { + switch p { + case Etsy: + return "Etsy" + case Tiktok: + return "Tiktok" + case Wix: + return "Wix" + case Ebay: + return "Ebay" + case WalmartMarketplace: + return "Walmart Marketplace" + case Amazon: + return "Amazon" + case BigCartel: + return "Big Cartel" + case Ecwid: + return "Ecwid" + case Zoho: + return "Zoho" + case SquareOnline: + return "Square Online" + case Squarespace: + return "Squarespace" + case WooCommerce: + return "Woo Commerce" + case Shopify: + return "Shopify" + } + return "" +} + // sql.Scanner implementation func (p *Platform) Scan(src any) error { var s string diff --git a/internal/domains/accounts/store_with_context.go b/internal/domains/accounts/store_with_context.go index 08f5796..6434abb 100644 --- a/internal/domains/accounts/store_with_context.go +++ b/internal/domains/accounts/store_with_context.go @@ -51,6 +51,18 @@ func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Accoun return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID) } +func (v_ctx *StoreWithContext) SetOrderOfPlatformOnAccountPage(acctID int64, platform Platform, orderIndex int) error { + return v_ctx.Store.SetOrderOfPlatformOnAccountPage(v_ctx.ctx, acctID, platform, orderIndex) +} + +func (v_ctx *StoreWithContext) GetOrderOfPlatformsOnAccountPage(acctID int64) ([]Platform, error) { + return v_ctx.Store.GetOrderOfPlatformsOnAccountPage(v_ctx.ctx, acctID) +} + +func (v_ctx *StoreWithContext) GetOrderOfPlatformOnAccountPage(acctID int64, platform Platform) (int, error) { + return v_ctx.Store.GetOrderOfPlatformOnAccountPage(v_ctx.ctx, acctID, platform) +} + func (v_ctx *StoreWithContext) CreateSyncGroupListingDraft(acctID int64) (int, error) { return v_ctx.Store.CreateSyncGroupListingDraft(v_ctx.ctx, acctID) } diff --git a/internal/server/api/accounts/router.go b/internal/server/api/accounts/router.go index e73cb21..5dbf6fc 100644 --- a/internal/server/api/accounts/router.go +++ b/internal/server/api/accounts/router.go @@ -2,6 +2,7 @@ package accounts import ( "errors" + "fmt" "strconv" "github.com/gin-gonic/gin" @@ -32,9 +33,10 @@ func Routes( } r.POST("", response.Handler(as.createAccount)) + r.PUT("/:acctID/platforms/:platform/order-index", pub.Publish("/:acctID/platforms"), response.Handler(as.setOrderOfPlatformOnAccountPage)) syncGroups := r.Group("/:acctID/inventory/sync-groups") - syncGroups.POST("", response.Handler(as.saveNewSyncGroup)) + syncGroups.POST("", pub.Publish("/:acctID/inventory/sync-groups"), response.Handler(as.saveNewSyncGroup)) draftListings := syncGroups.Group("/draft/listings", pub.Publish("/:acctID/inventory/sync-groups/draft/listings")) draftListings.POST("", response.Handler(as.createSyncGroupListingDraft)) @@ -184,3 +186,39 @@ func getOrderIndexForSyncGroupListingDraftFromPath(c *gin.Context) (int, error) return orderIndex, nil } + +func (s *accountSubrouter) setOrderOfPlatformOnAccountPage(c *gin.Context) (response.Response, error) { + acctID := auth.GetIdentity(c).Account.AccountID + + var orderIndex int + if v, ok := c.GetPostForm("order-index"); !ok { + return nil, response.BadRequest(). + Msg("no order-index provided") + } else if i, err := strconv.Atoi(v); err != nil { + return nil, response.BadRequest(). + Msgf("order-index must be a non-negative integer: %s", v) + } else if i < 0 { + return nil, response.BadRequest(). + Msgf("order-index must be a non-negative integer: %s", v) + } else { + orderIndex = i + } + + var platform accounts.Platform + if v := c.Param("platform"); v == "" { + return nil, response.BadRequest(). + Msg("no platform provided") + } else if p, err := accounts.NewPlatform(v); err != nil { + return nil, response.BadRequest(). + Wrap(err). + Msgf("unrecognized platform: %v", v) + } else { + platform = p + } + + if err := s.accts.SetOrderOfPlatformOnAccountPage(c, acctID, platform, orderIndex); err != nil { + return nil, fmt.Errorf("failed to save record: %w", err) + } + + return response.Status(200), nil +} diff --git a/internal/server/response/error.go b/internal/server/response/error.go index a4f8c12..5513734 100644 --- a/internal/server/response/error.go +++ b/internal/server/response/error.go @@ -150,7 +150,13 @@ func (e ErrorResponse) GetHTML() ([]byte, bool) { } func GetError(err error) (e ErrorResponse, ok bool) { - ok = errors.As(err, &e) + if ok = errors.As(err, &e); ok { + return e, true + } + var ptr *ErrorResponse + if ok = errors.As(err, &ptr); ok { + return *ptr, true + } return e, ok } diff --git a/internal/server/response/write.go b/internal/server/response/write.go index a56a260..f62c98a 100644 --- a/internal/server/response/write.go +++ b/internal/server/response/write.go @@ -1,6 +1,7 @@ package response import ( + "errors" "fmt" "io" "net/http" @@ -45,6 +46,11 @@ func HandleErrors(c *gin.Context) { } } if !ok { + var err error + for _, e := range c.Errors { + err = errors.Join(err, e) + } + c.String(http.StatusInternalServerError, err.Error()) return } diff --git a/scripts/hx-drag.js b/scripts/hx-drag.js new file mode 100644 index 0000000..6fc5dbf --- /dev/null +++ b/scripts/hx-drag.js @@ -0,0 +1,172 @@ +(function(){ + /** + * @type {object} + */ + const htmx = window.htmx; + + htmx.defineExtension("drag", { + init: (node) => { + // ideally it should only start listening to elements with hx-ext="drag" + // I assume there is a better way I can bind than this? + document.addEventListener("dragstart", DragStart); + document.addEventListener("dragover", DragOver); + document.addEventListener("dragleave", DragLeave); + document.addEventListener("dragend", DragEnd); + document.addEventListener("drop", Drop); + } + }); + + /** + * the most recent element a drag event started on + * @type {Element|null} + */ + let dragging = null; + + /** + * Get the parent element which matches the selector + * @param {EventTarget|null} target + * @param {string} selector + * @returns {Element | null} + */ + function GetTarget(target, selector) { + if (target == null) return null; + if (!(target instanceof Element)) return null; + + target = target.closest(`[${selector}],[data-${selector}]`); + if (target == null) return null; + if (!(target instanceof Element)) return null; + + return target; + } + + /** + * Get the attribute allowing `data-` fallback + * @param {Element | null} target + * @param {string} name + * @returns {string | null} + */ + function GetAttribute(target, name) { + if (target === null) return null; + return target.getAttribute(name) || target.getAttribute("data-"+name); + } + + /** + * @param {DragEvent} ev + */ + function DragStart(ev) { + if (!ev.dataTransfer) return; + + const target = GetTarget(ev.target, "hx-drag"); + if (!target) return null; + + const data = GetAttribute(target, "hx-drag"); + if (data === null) return; + + target.classList.add("hx-drag"); + dragging = target; + } + + /** + * @param {DragEvent} ev + */ + function DragOver(ev) { + if (!dragging) return; + + const target = GetTarget(ev.target, "hx-drop"); + if (!target) return; + ev.preventDefault(); + + target.classList.add("hx-drag-over"); + } + + /** + * @param {DragEvent} ev + */ + function DragLeave(ev) { + if (!dragging) return; + + const target = GetTarget(ev.target, "hx-drop"); + if (!target) return; + + target.classList.remove("hx-drag-over"); + } + + /** + * @param {DragEvent} ev + */ + function DragEnd() { + if (!dragging) return; + dragging.classList.remove("hx-drag"); + } + + /** + * @param {DragEvent} ev + */ + async function Drop(ev) { + if (!dragging) return; + + const drag = dragging; + const drop = GetTarget(ev.target, "hx-drop"); + if (!drop) return; + drop.classList.add("hx-drop"); + drop.classList.remove("hx-drag-over"); + + const dragAttr = GetAttribute(drag, "hx-drag"); + const dropAttr = GetAttribute(drop, "hx-drop"); + + console.log('dragAttr:', dragAttr); + console.log('dropAttr:', dropAttr); + + const dragVals = JSON.parse(GetAttribute(drag, "hx-drag") || "{}"); + const dropVals = JSON.parse(GetAttribute(drop, "hx-drop") || "{}"); + + const precedence = GetAttribute(drag, "hx-drag-sync") || GetAttribute(drag, "hx-drop-sync"); + const dragFirst = precedence === "drag"; + const sync = !!precedence; + + drop.classList.add("htmx-request"); + drag.classList.add("htmx-request"); + const queue = dragFirst ? [drag, drop] : [drop, drag]; + for (const elm of queue) { + const promise = RunDragDrop(elm, elm === drag, dragVals, dropVals); + if (sync) await promise; + } + + drag.classList.remove("hx-drag"); + drop.classList.remove("hx-drop"); + dragging = null; + } + + /** + * + * @param {Element} source + * @param {boolean} isDrag + * @param {object} dragVals + * @param {object} dropVals + * @returns + */ + async function RunDragDrop(source, isDrag, dragVals, dropVals) { + if (!document.body.contains(source)) return; + + let method, action, values; + if (isDrag) { + action = GetAttribute(source, "hx-drag-action"); + method = GetAttribute(source, "hx-drag-method") || "PUT"; + values = Object.assign({}, dropVals, dragVals); + } else { + action = GetAttribute(source, "hx-drop-action"); + method = GetAttribute(source, "hx-drop-method") || "PUT"; + values = Object.assign({}, dragVals, dropVals); + } + + console.log('action:', action) + + if (action === null) return; + + try { // don't let one failure cascade to the other ajax + await htmx.ajax(method, action, { source, values }); + } catch (e) { + return; + } + } +})() diff --git a/templates/components/accounts/{acctID.int64}/shops/list-items/{platform}.html.tmpl b/templates/components/accounts/{acctID.int64}/shops/list-items/{platform}.html.tmpl new file mode 100644 index 0000000..d79eab2 --- /dev/null +++ b/templates/components/accounts/{acctID.int64}/shops/list-items/{platform}.html.tmpl @@ -0,0 +1,96 @@ +{{- $acctID := .Identity.Account.AccountID }} +{{- $platform := parsePlatform .PathParams.platform -}} +{{- $orderIndex := .OrderIndex }} +{{- if and (not $orderIndex) (ne .OrderIndex 0) }} + {{- $orderIndex = .Accounts.GetOrderOfPlatformOnAccountPage $acctID $platform -}} +{{- end }} + + +{{- $platformSegment := lowerCamelCase (printf "%s" $platform) }} +{{- $path := printf "/accounts/%d/platforms/%s/order-index" $acctID $platformSegment }} +