removed intermediate /internal directory

This commit is contained in:
2026-02-09 13:40:31 -07:00
parent b155280081
commit 0944703d2a
50 changed files with 117 additions and 101 deletions
+424
View File
@@ -0,0 +1,424 @@
package accounts
// to generate StoreWithContext
//go:generate concurry -s Store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"ruben/inventory2/consts"
"ruben/inventory2/logging"
)
type (
Store struct {
log *logging.Logger
db *pgxpool.Pool
}
Account struct {
AccountIDs
OAuthUser
Email string
}
OAuthUser struct {
UserID string
}
AccountShop struct {
AccountShopIDs
Name string
}
Listing struct {
AccountShopListingIDs
SKU string
Name string
Description string
Count int64
}
)
func NewStore(logger *logging.Logger, db *pgxpool.Pool) *Store {
return &Store{
log: logger,
db: db,
}
}
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
return NewStoreWithContext(ctx, db)
}
func (db *Store) CreateAccount(ctx context.Context, userID, email string) (Account, error) {
rows, err := db.db.Query(
ctx,
`
INSERT INTO accounts (
user_id,
email
)
VALUES (
@user_id,
@email
)
ON CONFLICT DO NOTHING
RETURNING
account_id
`,
pgx.NamedArgs{
"user_id": userID,
"email": email,
},
)
if err != nil {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
acctID, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, fmt.Errorf("account already exists: %w", consts.ErrConflict)
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
OAuthUser: OAuthUser{
UserID: userID,
},
AccountIDs: AccountIDs{
AccountID: acctID,
},
Email: email,
}, nil
}
func (db *Store) GetAccount(ctx context.Context, id int64) (Account, error) {
rows, err := db.db.Query(
ctx,
"SELECT email, user_id FROM accounts WHERE account_id = @account_id",
pgx.NamedArgs{
"account_id": id,
},
)
if err != nil {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Email string
User_id string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
AccountIDs: AccountIDs{
AccountID: id,
},
OAuthUser: OAuthUser{
r.User_id,
},
Email: r.Email,
}, nil
}
func (db *Store) GetAccountByUserID(ctx context.Context, userID string) (Account, error) {
rows, err := db.db.Query(
ctx,
`SELECT
email, account_id
FROM
accounts
WHERE
user_id = @user_id`,
pgx.NamedArgs{
"user_id": userID,
},
)
if err != nil {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Email string
Account_ID int64
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
AccountIDs: AccountIDs{
AccountID: r.Account_ID,
},
OAuthUser: OAuthUser{
UserID: userID,
},
Email: r.Email,
}, nil
}
func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account, error) {
rows, err := db.db.Query(
ctx,
"SELECT account_id, user_id FROM accounts WHERE email = @email",
pgx.NamedArgs{
"email": email,
},
)
if err != nil {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Account_id int64
User_id string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
AccountIDs: AccountIDs{
AccountID: r.Account_id,
},
OAuthUser: OAuthUser{
UserID: r.User_id,
},
Email: email,
}, nil
}
func (db *Store) GetUserAndAccountByAccessToken(ctx context.Context, accessToken string) (OAuthUser, *Account, error) {
rows, err := db.db.Query(
ctx,
`
SELECT
u.user_id,
a.account_id,
a.email
FROM oauth_users u
LEFT JOIN oauth_tokens t
ON u.user_id = id_token_subject
LEFT JOIN accounts a
USING (user_id)
WHERE access_token = @access_token
`,
pgx.NamedArgs{
"access_token": accessToken,
},
)
if err != nil {
return OAuthUser{}, nil, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
User_ID string
Account_ID *int64
Email *string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return OAuthUser{}, nil, consts.ErrNotFound
}
return OAuthUser{}, nil, fmt.Errorf("failed to scan row: %w", err)
}
user := OAuthUser{UserID: r.User_ID}
var acct *Account
if r.Account_ID != nil {
acct = &Account{
AccountIDs: AccountIDs{
AccountID: *r.Account_ID,
},
OAuthUser: user,
Email: *r.Email,
}
}
return user, acct, nil
}
func (db *Store) GetShops(ctx context.Context, acctID int64) ([]AccountShop, error) {
var shops []AccountShop
for _, v := range getDevShops(acctID) {
shops = append(shops, v)
}
return shops, nil
}
func (db *Store) GetMockShops(ctx context.Context, acctID int64) ([]AccountShop, error) {
infos := getAllMockShopSchemaInfos()
lists := make([][]AccountShop, len(infos))
numShops := 0
for i, in := range infos {
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
shop_id,
name
FROM
%s
WHERE
account_id = @account_id
`,
in.shopTable,
),
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Shop_id string
Name string
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
list := make([]AccountShop, len(vs))
for i, v := range vs {
list[i] = AccountShop{
AccountShopIDs: NewAccountIDs(acctID).
ShopID(in.platform, v.Shop_id),
Name: v.Name,
}
}
lists[i] = list
numShops += len(list)
}
shops := make([]AccountShop, 0, numShops)
for _, list := range lists {
shops = append(shops, list...)
}
return shops, nil
}
func (db *Store) GetListingsForShop(ctx context.Context, acctID int64, platform Platform, shopID string) ([]Listing, error) {
var vs []Listing
for _, v := range getDevListings(acctID) {
if v.Platform != platform {
continue
}
if v.ShopID != shopID {
continue
}
vs = append(vs, v)
}
return vs, nil
}
func (db *Store) GetMockListingsForShop(ctx context.Context, acctID int64, platform Platform, shopID string) ([]Listing, error) {
in, ok := getMockShopSchemaInfo(platform)
if !ok {
return nil, consts.ErrNotFound
}
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
listing_id,
sku,
name,
description,
"count"
FROM
%s
WHERE
account_id = @account_id
AND shop_id = @shop_id
`,
in.listingsTable,
),
pgx.NamedArgs{
"account_id": acctID,
"shop_id": shopID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Listing_id string
SKU string
Name string
Description string
Count int64
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
listings := make([]Listing, len(vs))
for i, v := range vs {
listings[i] = Listing{
AccountShopListingIDs: NewAccountIDs(acctID).
ShopID(in.platform, shopID).
ListingID(v.Listing_id),
SKU: v.SKU,
Name: v.Name,
Description: v.Description,
Count: v.Count,
}
}
return listings, nil
}
// additional context
func (db *Store) GetAccountPointerByUserID(ctx context.Context, userID string) (*Account, error) {
acct, err := db.GetAccountByUserID(ctx, userID)
if err == nil {
return &acct, nil
}
if errors.Is(err, consts.ErrNotFound) {
return nil, nil
}
return nil, err
}
+293
View File
@@ -0,0 +1,293 @@
package accounts
func getDevShops(acctID int64) []AccountShop {
return []AccountShop{
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
Name: "Etsy 1",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
Name: "Etsy 2",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "8",
},
Name: "Etsy 3",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Tiktok,
ShopID: "11",
},
Name: "Tiktok 1",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Tiktok,
ShopID: "14",
},
Name: "Tiktok 2",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Tiktok,
ShopID: "17",
},
Name: "Tiktok 3",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Wix,
ShopID: "20",
},
Name: "Wix 1",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Wix,
ShopID: "23",
},
Name: "Wix 2",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Wix,
ShopID: "26",
},
Name: "Wix 3",
},
}
}
func getDevListings(acctID int64) []Listing {
return []Listing{
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "1",
},
SKU: "sku 1",
Name: "name 1",
Description: "description 1",
Count: 51,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "2",
},
SKU: "sku 2",
Name: "name 2",
Description: "description 2",
Count: 52,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "3",
},
SKU: "sku 3",
Name: "name 3",
Description: "description 3",
Count: 53,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "4",
},
SKU: "sku 4",
Name: "name 4",
Description: "description 4",
Count: 54,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "5",
},
SKU: "sku 5",
Name: "name 5",
Description: "description 5",
Count: 55,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "6",
},
SKU: "sku 6",
Name: "name 6",
Description: "description 6",
Count: 56,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "7",
},
SKU: "sku 7",
Name: "name 7",
Description: "description 7",
Count: 57,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "8",
},
SKU: "sku 8",
Name: "name 8",
Description: "description 8",
Count: 58,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "9",
},
SKU: "sku 9",
Name: "name 9",
Description: "description 9",
Count: 59,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "10",
},
SKU: "sku 10",
Name: "name 10",
Description: "description 10",
Count: 60,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "11",
},
SKU: "sku 11",
Name: "name 11",
Description: "description 11",
Count: 61,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "12",
},
SKU: "sku 12",
Name: "name 12",
Description: "description 12",
Count: 62,
},
}
}
+39
View File
@@ -0,0 +1,39 @@
package accounts
type (
AccountIDs struct {
AccountID int64
}
AccountShopIDs struct {
AccountIDs
Platform Platform
ShopID string
}
AccountShopListingIDs struct {
AccountShopIDs
ListingID string
}
)
func NewAccountIDs(acctID int64) AccountIDs {
return AccountIDs{
AccountID: acctID,
}
}
func (ids AccountIDs) ShopID(p Platform, id string) AccountShopIDs {
return AccountShopIDs{
AccountIDs: ids,
Platform: p,
ShopID: id,
}
}
func (ids AccountShopIDs) ListingID(id string) AccountShopListingIDs {
return AccountShopListingIDs{
AccountShopIDs: ids,
ListingID: id,
}
}
+633
View File
@@ -0,0 +1,633 @@
package accounts
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"ruben/inventory2/consts"
)
type (
MockShop struct {
AccountShopIDs
Name string
}
MockListing struct {
AccountShopListingIDs
SKU string
Name string
Description string
Count int64
}
)
func (db *Store) CreateMockShop(ctx context.Context, acctID int64, platform Platform, name string) (shopID uuid.UUID, err error) {
shopTableName, err := getMockShopTableName(platform)
if err != nil {
return uuid.Nil, err
}
tx, err := db.db.Begin(ctx)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to being transaction: %w", err)
}
defer tx.Rollback(ctx)
// TODO: are we really going to just copy the id between accounts and mock.accounts?
// upsert mock account
rows, err := tx.Query(
ctx,
`
WITH existing_account(account_id, user_id) AS (
SELECT
account_id,
user_id
FROM
accounts
WHERE
account_id = @account_id
), existing_mock_account AS (
SELECT
user_id
FROM
accounts
WHERE
account_id = @account_id
), inserted_mock_account AS (
INSERT INTO
mock.accounts (
account_id,
user_id
)
SELECT
account_id,
user_id
FROM
existing_account
ON CONFLICT
DO NOTHING
RETURNING
user_id
)
SELECT
COALESCE(ia.user_id, ea.user_id)
FROM
inserted_mock_account ia
FULL OUTER JOIN
existing_mock_account ea
USING
(user_id)
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query: %w", err)
}
userID, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[string])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, fmt.Errorf("%w: account not found", consts.ErrNotFound)
}
return uuid.Nil, fmt.Errorf("failed to scan rows: %w", err)
}
// create the mock shop
shopID = uuid.New()
_, err = tx.Exec(
ctx,
fmt.Sprintf(
`
INSERT INTO
%s (
user_id,
account_id,
shop_id,
name
)
VALUES (
@user_id,
@account_id,
@shop_id,
@name
)
`,
shopTableName,
),
pgx.NamedArgs{
"account_id": acctID,
"user_id": userID,
"shop_id": shopID,
"name": name,
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query to create mock shop: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return uuid.Nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return shopID, nil
}
func (db *Store) ListMockShopsForPlatform(ctx context.Context, acctID int64, platform Platform) ([]MockShop, error) {
shopTableName, err := getMockShopTableName(platform)
if err != nil {
return nil, err
}
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
shop_id,
name
FROM
%s
WHERE
account_id = @account_id
`,
shopTableName,
),
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Shop_id uuid.UUID
Name string
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
shops := make([]MockShop, len(vs))
for i, v := range vs {
shops[i] = MockShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: platform,
ShopID: v.Shop_id.String(),
},
Name: v.Name,
}
}
return shops, nil
}
func (db *Store) GetMockShop(ctx context.Context, acctID int64, platform Platform, shopID string) (*MockShop, error) {
shopTableName, err := getMockShopTableName(platform)
if err != nil {
return nil, err
}
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
name
FROM
%s
WHERE
account_id = @account_id
AND shop_id = @shop_id
`,
shopTableName,
),
pgx.NamedArgs{
"account_id": acctID,
"shop_id": shopID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
name, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[string])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, consts.ErrNotFound
}
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
return &MockShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: platform,
ShopID: shopID,
},
Name: name,
}, nil
}
func (db *Store) CreateMockListing(ctx context.Context, listing MockListing) (MockListing, error) {
shopTableName, listingTableName, err := getMockShopAndListingsTableNames(listing.Platform)
if err != nil {
return MockListing{}, err
}
listing.ListingID = uuid.New().String()
tag, err := db.db.Exec(
ctx,
fmt.Sprintf(
`
WITH existing_shop(account_id, shop_id) AS (
SELECT
account_id,
shop_id
FROM
%s
WHERE
account_id = @account_id
AND shop_id = @shop_id
), new_values(listing_id, sku, name, description, "count") AS (
SELECT
@listing_id::text,
@sku::text,
@name::text,
@description::text,
@count::integer
)
INSERT INTO
%s (
account_id,
shop_id,
listing_id,
sku,
name,
description,
count
)
SELECT
account_id,
shop_id,
listing_id,
sku,
name,
description,
"count"
FROM
existing_shop
JOIN
new_values
ON
true
`,
shopTableName,
listingTableName,
),
pgx.NamedArgs{
"account_id": listing.AccountID,
"shop_id": listing.ShopID,
"listing_id": listing.ListingID,
"sku": listing.SKU,
"name": listing.Name,
"description": listing.Description,
"count": listing.Count,
},
)
if err != nil {
return MockListing{}, fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() == 0 {
return MockListing{}, fmt.Errorf("%w: shop not found", consts.ErrNotFound)
}
return listing, nil
}
func (db *Store) UpdateMockListing(ctx context.Context, listing MockListing) error {
_, listingTableName, err := getMockShopAndListingsTableNames(listing.Platform)
if err != nil {
return err
}
tag, err := db.db.Exec(
ctx,
fmt.Sprintf(
`
UPDATE
%s
SET
sku = @sku,
name = @name,
description = @description,
"count" = @count
WHERE
account_id = @account_id
AND shop_id = @shop_id
AND listing_id = @listing_id
`,
listingTableName,
),
pgx.NamedArgs{
"account_id": listing.AccountID,
"shop_id": listing.ShopID,
"listing_id": listing.ListingID,
"sku": listing.SKU,
"name": listing.Name,
"description": listing.Description,
"count": listing.Count,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: listing not found", consts.ErrNotFound)
}
return nil
}
// TODO: will have to be updated once we start creating mock sync groups
func (db *Store) DeleteMockListing(ctx context.Context, ids AccountShopListingIDs) error {
_, listingTableName, err := getMockShopAndListingsTableNames(ids.Platform)
if err != nil {
return err
}
tag, err := db.db.Exec(
ctx,
fmt.Sprintf(
`
DELETE FROM
%s
WHERE
account_id = @account_id
AND shop_id = @shop_id
AND listing_id = @listing_id
`,
listingTableName,
),
pgx.NamedArgs{
"account_id": ids.AccountID,
"shop_id": ids.ShopID,
"listing_id": ids.ListingID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: listing not found", consts.ErrNotFound)
}
return nil
}
func (db *Store) ListMockListingsForShop(ctx context.Context, acctID int64, platform Platform, shopID string) ([]MockListing, error) {
shopTableName, listingsTableName, err := getMockShopAndListingsTableNames(platform)
if err != nil {
return nil, err
}
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
true AS shop_exists,
l.listing_id,
l.sku,
l.name,
l.description,
l."count"
FROM
%s AS s
LEFT JOIN
%s AS l
USING
(account_id, shop_id)
WHERE
(account_id IS NULL OR account_id = @account_id)
AND (shop_id IS NULL OR shop_id = @shop_id)
ORDER BY
name
`,
shopTableName,
listingsTableName,
),
pgx.NamedArgs{
"account_id": acctID,
"shop_id": shopID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Shop_exists bool
Listing_id pgtype.Text
SKU pgtype.Text
Name pgtype.Text
Description pgtype.Text
Count pgtype.Int8
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
// check if the shop exists, and confirm there were actually listings (LEFT JOIN)
if len(vs) == 0 {
return nil, fmt.Errorf("%w: shop not found", consts.ErrNotFound)
}
if !vs[0].SKU.Valid {
// shop exists, but there are not listings
return nil, nil
}
listings := make([]MockListing, len(vs))
for i, v := range vs {
listings[i] = MockListing{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: platform,
ShopID: shopID,
},
ListingID: v.Listing_id.String,
},
SKU: v.SKU.String,
Name: v.Name.String,
Description: v.Description.String,
Count: v.Count.Int64,
}
}
return listings, nil
}
func getMockShopTableName(platform Platform) (string, error) {
switch platform {
case Amazon:
return "mock.shop_amazon", nil
case BigCartel:
return "mock.shop_big_cartel", nil
case Ebay:
return "mock.shop_ebay", nil
case Ecwid:
return "mock.shop_ecwid", nil
case Etsy:
return "mock.shop_etsy", nil
case Shopify:
return "mock.shop_shopify", nil
case SquareOnline:
return "mock.shop_square_online", nil
case Squarespace:
return "mock.shop_squarespace", nil
case Tiktok:
return "mock.shop_tiktok", nil
case WalmartMarketplace:
return "mock.shop_walmart_marketplace", nil
case Wix:
return "mock.shop_wix", nil
case WooCommerce:
return "mock.shop_woo_commerce", nil
case Zoho:
return "mock.shop_zoho", nil
default:
return "", fmt.Errorf("%w: unrecognized platform", consts.ErrBadRequest)
}
}
type mockShopSchemaInfo struct {
platform Platform
shopTable string
listingsTable string
syncGroupListingDraftsTable string
}
func getMockShopSchemaInfo(platform Platform) (mockShopSchemaInfo, bool) {
for _, in := range getAllMockShopSchemaInfos() {
if in.platform == platform {
return in, true
}
}
return mockShopSchemaInfo{}, false
}
func getAllMockShopSchemaInfos() []mockShopSchemaInfo {
return []mockShopSchemaInfo{
{
platform: Amazon,
shopTable: "mock.shop_amazon",
listingsTable: "mock.shop_amazon_listings",
syncGroupListingDraftsTable: "mock.shop_amazon_sync_group_listing_drafts",
},
{
platform: BigCartel,
shopTable: "mock.shop_big_cartel",
listingsTable: "mock.shop_big_cartel_listings",
syncGroupListingDraftsTable: "mock.shop_big_cartel_sync_group_listing_drafts",
},
{
platform: Ebay,
shopTable: "mock.shop_ebay",
listingsTable: "mock.shop_ebay_listings",
syncGroupListingDraftsTable: "mock.shop_ebay_sync_group_listing_drafts",
},
{
platform: Ecwid,
shopTable: "mock.shop_ecwid",
listingsTable: "mock.shop_ecwid_listings",
syncGroupListingDraftsTable: "mock.shop_ecwid_sync_group_listing_drafts",
},
{
platform: Etsy,
shopTable: "mock.shop_etsy",
listingsTable: "mock.shop_etsy_listings",
syncGroupListingDraftsTable: "mock.shop_etsy_sync_group_listing_drafts",
},
{
platform: Shopify,
shopTable: "mock.shop_shopify",
listingsTable: "mock.shop_shopify_listings",
syncGroupListingDraftsTable: "mock.shop_shopify_sync_group_listing_drafts",
},
{
platform: SquareOnline,
shopTable: "mock.shop_square_online",
listingsTable: "mock.shop_square_online_listings",
syncGroupListingDraftsTable: "mock.shop_square_online_sync_group_listing_drafts",
},
{
platform: Squarespace,
shopTable: "mock.shop_squarespace",
listingsTable: "mock.shop_squarespace_listings",
syncGroupListingDraftsTable: "mock.shop_squarespace_sync_group_listing_drafts",
},
{
platform: Tiktok,
shopTable: "mock.shop_tiktok",
listingsTable: "mock.shop_tiktok_listings",
syncGroupListingDraftsTable: "mock.shop_tiktok_sync_group_listing_drafts",
},
{
platform: WalmartMarketplace,
shopTable: "mock.shop_walmart_marketplace",
listingsTable: "mock.shop_walmart_marketplace_listings",
syncGroupListingDraftsTable: "mock.shop_walmart_marketplace_sync_group_listing_drafts",
},
{
platform: Wix,
shopTable: "mock.shop_wix",
listingsTable: "mock.shop_wix_listings",
syncGroupListingDraftsTable: "mock.shop_wix_sync_group_listing_drafts",
},
{
platform: WooCommerce,
shopTable: "mock.shop_woo_commerce",
listingsTable: "mock.shop_woo_commerce_listings",
syncGroupListingDraftsTable: "mock.shop_woo_commerce_sync_group_listing_drafts",
},
{
platform: Zoho,
shopTable: "mock.shop_zoho",
listingsTable: "mock.shop_zoho_listings",
syncGroupListingDraftsTable: "mock.shop_zoho_sync_group_listing_drafts",
},
}
}
func getMockShopAndListingsTableNames(platform Platform) (shop string, listing string, err error) {
shop, err = getMockShopTableName(platform)
if err != nil {
return "", "", err
}
return shop, shop + "_listings", nil
}
+284
View File
@@ -0,0 +1,284 @@
package accounts
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/jackc/pgx/v5"
"ruben/inventory2/consts"
)
// TODO: simpify, if possible (single query ideal)
func (db *Store) SetOrderOfPlatformOnAccountPage(
ctx context.Context,
acctID int64,
platform Platform,
orderIndex int,
) (orderedPlatforms []Platform, prevOrderIndex int, err error) {
tx, err := db.db.Begin(ctx)
if err != nil {
return nil, 0, 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 nil, 0, 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 nil, 0, 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]
orderedPlatforms = make([]Platform, len(allPlatforms))
if indexPerPlatform[platform] == orderIndex {
for i := range allPlatforms {
orderedPlatforms[i] = platformPerIndex[i]
}
return orderedPlatforms, prevOrderIndex, 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]
orderedPlatforms[i] = platformPerIndex[i]
}
_, 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 nil, 0, fmt.Errorf("failed to perform query to delete old indexes and insert new indexes: %w", err)
}
if tx.Commit(ctx); err != nil {
return nil, 0, fmt.Errorf("failed to commit txn: %w", err)
}
return orderedPlatforms, prevOrderIndex, 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
}
+110
View File
@@ -0,0 +1,110 @@
package accounts
import (
"fmt"
"strings"
)
type (
Platform string
)
const (
Amazon Platform = "amazon"
BigCartel Platform = "big_cartel"
Ebay Platform = "ebay"
Ecwid Platform = "ecwid"
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"
Zoho Platform = "zoho"
)
var (
allPlatforms = []Platform{
Amazon,
BigCartel,
Ebay,
Ecwid,
Etsy,
Shopify,
SquareOnline,
Squarespace,
Tiktok,
WalmartMarketplace,
Wix,
WooCommerce,
Zoho,
}
)
func NewPlatform(s string) (Platform, error) {
for _, v := range allPlatforms {
if strings.ToLower(s) == strings.ToLower(string(v)) {
return v, nil
}
}
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
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
}
+153
View File
@@ -0,0 +1,153 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package accounts
import (
"context"
"github.com/google/uuid"
)
type StoreWithContext struct {
ctx context.Context
*Store
}
func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
Store: v,
}
}
func (v_ctx *StoreWithContext) CreateAccount(userID string, email string) (Account, error) {
return v_ctx.Store.CreateAccount(v_ctx.ctx, userID, email)
}
func (v_ctx *StoreWithContext) GetAccount(id int64) (Account, error) {
return v_ctx.Store.GetAccount(v_ctx.ctx, id)
}
func (v_ctx *StoreWithContext) GetAccountByUserID(userID string) (Account, error) {
return v_ctx.Store.GetAccountByUserID(v_ctx.ctx, userID)
}
func (v_ctx *StoreWithContext) GetAccountByEmail(email string) (Account, error) {
return v_ctx.Store.GetAccountByEmail(v_ctx.ctx, email)
}
func (v_ctx *StoreWithContext) GetUserAndAccountByAccessToken(accessToken string) (OAuthUser, *Account, error) {
return v_ctx.Store.GetUserAndAccountByAccessToken(v_ctx.ctx, accessToken)
}
func (v_ctx *StoreWithContext) GetShops(acctID int64) ([]AccountShop, error) {
return v_ctx.Store.GetShops(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) GetMockShops(acctID int64) ([]AccountShop, error) {
return v_ctx.Store.GetMockShops(v_ctx.ctx, acctID)
}
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) GetMockListingsForShop(acctID int64, platform Platform, shopID string) ([]Listing, error) {
return v_ctx.Store.GetMockListingsForShop(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) CreateMockShop(acctID int64, platform Platform, name string) (uuid.UUID, error) {
return v_ctx.Store.CreateMockShop(v_ctx.ctx, acctID, platform, name)
}
func (v_ctx *StoreWithContext) ListMockShopsForPlatform(acctID int64, platform Platform) ([]MockShop, error) {
return v_ctx.Store.ListMockShopsForPlatform(v_ctx.ctx, acctID, platform)
}
func (v_ctx *StoreWithContext) GetMockShop(acctID int64, platform Platform, shopID string) (*MockShop, error) {
return v_ctx.Store.GetMockShop(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) CreateMockListing(listing MockListing) (MockListing, error) {
return v_ctx.Store.CreateMockListing(v_ctx.ctx, listing)
}
func (v_ctx *StoreWithContext) UpdateMockListing(listing MockListing) error {
return v_ctx.Store.UpdateMockListing(v_ctx.ctx, listing)
}
func (v_ctx *StoreWithContext) DeleteMockListing(ids AccountShopListingIDs) error {
return v_ctx.Store.DeleteMockListing(v_ctx.ctx, ids)
}
func (v_ctx *StoreWithContext) ListMockListingsForShop(acctID int64, platform Platform, shopID string) ([]MockListing, error) {
return v_ctx.Store.ListMockListingsForShop(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) SetOrderOfPlatformOnAccountPage(acctID int64, platform Platform, orderIndex int) ([]Platform, 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)
}
func (v_ctx *StoreWithContext) CreateMockSyncGroupListingDraft(acctID int64) (int, error) {
return v_ctx.Store.CreateMockSyncGroupListingDraft(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) GetMockSyncGroupListingDraft(acctID int64, orderIndex int) (SyncGroupListingDraft, error) {
return v_ctx.Store.GetMockSyncGroupListingDraft(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) SetShopInMockSyncGroupListingDraft(acctID int64, orderIndex int, platform Platform, shopID string) error {
return v_ctx.Store.SetShopInMockSyncGroupListingDraft(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) SetListingInMockSyncGroupListingDraft(acctID int64, orderIndex int, listingID string) error {
return v_ctx.Store.SetListingInMockSyncGroupListingDraft(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) DeleteMockSyncGroupListingDraft(acctID int64, orderIndex int) error {
return v_ctx.Store.DeleteMockSyncGroupListingDraft(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) GetMockSyncGroupListingDrafts(acctID int64) ([]SyncGroupListingDraft, error) {
return v_ctx.Store.GetMockSyncGroupListingDrafts(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) SaveNewSyncGroup(acctID int64) (SyncGroup, error) {
return v_ctx.Store.SaveNewSyncGroup(v_ctx.ctx, acctID)
}
+820
View File
@@ -0,0 +1,820 @@
package accounts
import (
"context"
"errors"
"fmt"
"slices"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"ruben/inventory2/consts"
)
type (
SyncGroup struct {
SyncGroupIDs
Listings []SyncGroupListing
}
SyncGroupListing struct {
SyncGroupIDs
AccountShopIDs
ListingID string
OrderIndex int
}
SyncGroupIDs struct {
AccountIDs
SyncGroupID int64
}
// TODO: the id ACTUALLY is the account_id and order_index
SyncGroupListingDraft struct {
AccountShopIDs
ListingID string
OrderIndex int
}
)
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) CreateMockSyncGroupListingDraft(ctx context.Context, acctID int64) (orderIndex int, err error) {
rows, err := db.db.Query(
ctx,
`
WITH max_order_index(max_index) AS (
SELECT
MAX(order_index) AS max_index
FROM
mock.sync_group_listing_draft_order_indexes
)
INSERT INTO
mock.sync_group_listing_draft_order_indexes (
account_id,
order_index
)
SELECT
@account_id,
COALESCE(max_index, -1) + 1
FROM
max_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
Order_index *int
}])
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),
OrderIndex: orderIndex,
}, nil
}
func (db *Store) GetMockSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int) (SyncGroupListingDraft, error) {
tx, err := db.db.BeginTx(ctx, pgx.TxOptions{
AccessMode: pgx.ReadOnly,
})
if err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(
ctx,
`
SELECT
true
FROM
mock.sync_group_listing_draft_order_indexes
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 scan rows: %w", err)
}
if _, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[bool]); err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to scan rows: %w", err)
}
for _, info := range getAllMockShopSchemaInfos() {
rows, err := tx.Query(
ctx,
fmt.Sprintf(
`
SELECT
shop_id,
listing_id
FROM
%s
WHERE
account_id = @account_id
AND order_index = @order_index
`,
info.syncGroupListingDraftsTable,
),
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to perform query: %w", err)
}
v, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[struct {
Shop_id pgtype.Text
Listing_id pgtype.Text
}])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
continue
}
return SyncGroupListingDraft{}, fmt.Errorf("faild to scan row: %w", err)
}
return SyncGroupListingDraft{
AccountShopIDs: NewAccountIDs(acctID).
ShopID(info.platform, v.Shop_id.String),
ListingID: v.Listing_id.String,
OrderIndex: orderIndex,
}, nil
}
if err := tx.Commit(ctx); err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to commit transaction: %w", err)
}
return SyncGroupListingDraft{
AccountShopIDs: NewAccountIDs(acctID).
ShopID("", ""),
OrderIndex: orderIndex,
}, nil
}
func (db *Store) SetShopInSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, platform Platform, shopID string) error {
tag, 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 tag.RowsAffected() != 1 {
return consts.ErrNotFound
}
return nil
}
func (db *Store) SetShopInMockSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, platform Platform, shopID string) error {
info, ok := getMockShopSchemaInfo(platform)
if !ok {
return fmt.Errorf("%w: unrecognized platform: %s", consts.ErrNotFound, platform)
}
tx, err := db.db.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(
ctx,
`
DELETE FROM
mock.sync_group_listing_draft_order_indexes
WHERE
account_id = @account_id
AND order_index = @order_index
RETURNING
account_id,
order_index
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
return fmt.Errorf("failed to perform query deleting existing index: %w", err)
}
_, err = tx.Exec(
ctx,
`
INSERT INTO
mock.sync_group_listing_draft_order_indexes (
account_id,
order_index
)
VALUES (
@account_id,
@order_index
)
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
// TODO: handle error where account doesn't exist?
return fmt.Errorf("failed to perform query inserting new index: %w", err)
}
_, err = tx.Exec(
ctx,
fmt.Sprintf(
`
INSERT INTO
%s (
account_id,
order_index,
shop_id
)
VALUES (
@account_id,
@order_index,
@shop_id
)
`,
info.syncGroupListingDraftsTable,
),
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
"shop_id": shopID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query inserting specific shop index: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
func (db *Store) SetListingInSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, listingID string) error {
tag, 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 tag.RowsAffected() != 1 {
return consts.ErrNotFound
}
return nil
}
func (db *Store) SetListingInMockSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, listingID string) error {
for _, info := range getAllMockShopSchemaInfos() {
tag, err := db.db.Exec(
ctx,
fmt.Sprintf(
`
UPDATE
%s
SET
listing_id = @listing_id
WHERE
account_id = @account_id
AND order_index = @order_index
`,
info.syncGroupListingDraftsTable,
),
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
"listing_id": listingID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() > 0 {
return nil
}
}
return consts.ErrNotFound
}
func (db *Store) DeleteSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int) (numOfRows int, err error) {
rows, err := db.db.Query(
ctx,
`
WITH deleted_draft AS (
DELETE FROM
sync_group_listing_drafts
WHERE
account_id = @account_id
AND order_index = @order_index
RETURNING
true AS found
)
SELECT
COUNT(*) as num_rows,
COALESCE(dd.found, FALSE) as found
FROM
sync_group_listing_drafts ld
LEFT JOIN
deleted_draft dd
ON TRUE
WHERE
account_id = @account_id
GROUP BY
ld.account_id, dd.found
`,
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) DeleteMockSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int) error {
tag, err := db.db.Exec(
ctx,
`
DELETE FROM
mock.sync_group_listing_draft_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() != 1 {
return consts.ErrNotFound
}
return 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
ORDER BY
order_index ASC
`,
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 i, r := range rs {
listings[i] = SyncGroupListingDraft{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: deref(r.Platform),
ShopID: deref(r.Shop_id),
},
ListingID: deref(r.Listing_id),
OrderIndex: r.Order_index,
}
}
slices.SortFunc(listings, func(a, b SyncGroupListingDraft) int {
return a.OrderIndex - b.OrderIndex
})
return listings, nil
}
func (db *Store) GetMockSyncGroupListingDrafts(ctx context.Context, acctID int64) ([]SyncGroupListingDraft, error) {
tx, err := db.db.BeginTx(ctx, pgx.TxOptions{
AccessMode: pgx.ReadOnly,
})
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(
ctx,
`
SELECT
order_index
FROM
mock.sync_group_listing_draft_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)
}
indexes, err := pgx.CollectRows(rows, pgx.RowTo[int])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
listingsByOrderIndex := make(map[int]SyncGroupListingDraft, len(indexes))
for _, n := range indexes {
listingsByOrderIndex[n] = SyncGroupListingDraft{
AccountShopIDs: NewAccountIDs(acctID).
ShopID("", ""),
OrderIndex: n,
}
}
for _, info := range getAllMockShopSchemaInfos() {
rows, err := tx.Query(
ctx,
fmt.Sprintf(
`
SELECT
shop_id,
order_index,
listing_id
FROM
%s
WHERE
account_id = @account_id
`,
info.syncGroupListingDraftsTable,
),
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Shop_id pgtype.Text
Order_index pgtype.Int8
Listing_id pgtype.Text
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
for _, v := range vs {
n := int(v.Order_index.Int64)
l := listingsByOrderIndex[n]
l.Platform = info.platform
l.ShopID = v.Shop_id.String
l.ListingID = v.Listing_id.String
listingsByOrderIndex[n] = l
}
}
listings := make([]SyncGroupListingDraft, 0, len(listingsByOrderIndex))
for _, v := range listingsByOrderIndex {
listings = append(listings, v)
}
slices.SortFunc(listings, func(a, b SyncGroupListingDraft) int {
return a.OrderIndex - b.OrderIndex
})
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return listings, nil
}
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 i, r := range rs {
listings[i] = 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,
OrderIndex: r.Order_index,
}
}
slices.SortFunc(listings, func(a, b SyncGroupListing) int {
return a.OrderIndex - b.OrderIndex
})
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
}
+413
View File
@@ -0,0 +1,413 @@
package authentication
import (
"context"
"errors"
"fmt"
"net/url"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/oauth2"
"ruben/inventory2/consts"
"ruben/inventory2/logging"
)
// TODO: move these to a config?
const (
// The URL of our Auth0 Tenant Domain.
// If you're using a Custom Domain, be sure to set this to that value instead.
AUTH0_DOMAIN = "dev-uq3gqy5bdnwxmr6d.us.auth0.com"
// Our Auth0 application"s Client ID.
AUTH0_CLIENT_ID = "JEjrXTQ9fxlTLgp9RgTIACpUk8a2lqNT"
// Our Auth0 application"s Client Secret.
AUTH0_CLIENT_SECRET = "83U-iWdVaNnwk9XDzteo_2VMyOq_l1siKYqg1_2E7jCzgL8MnkaxlysPMcPMGlxA"
// The Callback URL of our application.
AUTH0_CALLBACK_URL = "https://inventory-plus-plus.com/api/auth/login/callback"
)
type (
// Authenticator is used to authenticate our users.
Authenticator struct {
log *logging.Logger
*oidc.Provider
oauth2.Config
db *pgxpool.Pool
}
// AccessTokenClaims is the claims Auth0 provides in access tokens
AccessTokenClaims struct {
Audience string `json:"aud"` // TODO: fill in from db
Expires int64 `json:"exp"`
Expiration time.Time `json:"-"` // parsed Expires
FamilyName string `json:"family_name"`
GivenName string `json:"given_name"`
IssuedAt int64 `json:"iat"` // TODO: fill in from db
Issuer string `json:"iss"` // TODO: fill in from db
Name string `json:"name"`
Nickname string `json:"nickname"`
Picture string `json:"picture"`
SessionID string `json:"sid"` // TODO: fill in from db
Subject string `json:"sub"` // TODO: fill in from db
UpdatedAt time.Time `json:"updated_at"` // TODO: fill in from db
}
)
// New instantiates the *Authenticator.
func New(
ctx context.Context,
db *pgxpool.Pool,
logger *logging.Logger,
) (*Authenticator, error) {
provider, err := oidc.NewProvider(
ctx,
"https://"+AUTH0_DOMAIN+"/",
)
if err != nil {
return nil, err
}
return &Authenticator{
log: logger,
Provider: provider,
Config: oauth2.Config{
ClientID: AUTH0_CLIENT_ID,
ClientSecret: AUTH0_CLIENT_SECRET,
RedirectURL: AUTH0_CALLBACK_URL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile"},
},
db: db,
}, nil
}
func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error {
for {
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil {
return fmt.Errorf("failed to delete all oauth_tokens rows that are expired: %w", err)
}
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_login_states WHERE expiration < NOW()"); err != nil {
return fmt.Errorf("failed to delete all oauth_login_states rows that are expired: %w", err)
}
select {
case <-ctx.Done():
return nil
case <-time.After(time.Minute):
}
}
}
// Exchange exchanges an auth code for an access token.
func (a *Authenticator) Exchange(ctx context.Context, state, code string) (accessToken, targetURI string, expiration time.Time, err error) {
// validate state
exp, targetURI, err := a.GetStateExpirationAndURL(ctx, state)
if errors.Is(err, consts.ErrNotFound) {
return "", "", time.Time{}, fmt.Errorf("invalid state: %w", consts.ErrNotFound)
} else if err != nil {
return "", "", time.Time{}, fmt.Errorf("failed to load state expiration: %w", err)
} else if exp.Before(time.Now()) {
return "", "", time.Time{}, fmt.Errorf("invalid state: state expired")
}
// obtain token and profile
tkn, err := a.Config.Exchange(ctx, code)
if err != nil {
return "", "", time.Time{}, fmt.Errorf("failed to exchange an authorization code for a token: %w", err)
}
idToken, claims, err := a.verifyIDTokenAndClaimsFromToken(ctx, tkn)
if err != nil {
return "", "", time.Time{}, err
}
// store the token, and the potentially new user
if _, err := a.db.Exec(
ctx,
`
WITH new_user AS (
INSERT INTO oauth_users (
user_id
)
VALUES (
@id_token_subject
)
ON CONFLICT DO NOTHING
RETURNING
user_id
), the_user AS (
SELECT
COALESCE(user_id, user_id_2) as user_id
FROM
new_user
RIGHT JOIN
(SELECT @id_token_subject as user_id_2)
ON TRUE
)
INSERT INTO oauth_tokens (
access_token,
token_type,
refresh_token,
expiry,
id_token_issuer,
id_token_audience,
id_token_subject,
id_token_expiry,
id_token_issued_at,
id_token_nonce,
id_token_access_token_hash,
id_token_custom_claims_family_name,
id_token_custom_claims_given_name,
id_token_custom_claims_name,
id_token_custom_claims_nickname,
id_token_custom_claims_picture,
id_token_custom_claims_updated_at
)
SELECT
@access_token,
@token_type,
@refresh_token,
@expiry,
@id_token_issuer,
@id_token_audience,
user_id,
@id_token_expiry,
@id_token_issued_at,
@id_token_nonce,
@id_token_access_token_hash,
@id_token_custom_claims_family_name,
@id_token_custom_claims_given_name,
@id_token_custom_claims_name,
@id_token_custom_claims_nickname,
@id_token_custom_claims_picture,
@id_token_custom_claims_updated_at
FROM
the_user
`,
pgx.NamedArgs{
"access_token": tkn.AccessToken,
"token_type": tkn.TokenType,
"refresh_token": tkn.RefreshToken,
"expiry": tkn.Expiry,
"id_token_issuer": idToken.Issuer,
"id_token_audience": pgtype.FlatArray[string](idToken.Audience),
"id_token_subject": idToken.Subject,
"id_token_expiry": idToken.Expiry,
"id_token_issued_at": idToken.IssuedAt,
"id_token_nonce": idToken.Nonce,
"id_token_access_token_hash": idToken.AccessTokenHash,
"id_token_custom_claims_family_name": claims.FamilyName,
"id_token_custom_claims_given_name": claims.GivenName,
"id_token_custom_claims_name": claims.Name,
"id_token_custom_claims_nickname": claims.Nickname,
"id_token_custom_claims_picture": claims.Picture,
"id_token_custom_claims_updated_at": claims.UpdatedAt,
},
); err != nil {
return "", "", time.Time{}, fmt.Errorf("failed to perform query to save tokens: %w", err)
}
return tkn.AccessToken, targetURI, tkn.Expiry.UTC(), nil
}
// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New("no id_token field in oauth2 token")
}
oidcConfig := &oidc.Config{
ClientID: a.ClientID,
}
return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
return &url.URL{
Scheme: "https",
Host: AUTH0_DOMAIN,
Path: "/v2/logout",
RawQuery: url.Values{
"returnTo": {
(&url.URL{
Scheme: "https",
Host: requestHost,
}).String(),
},
"client_id": {AUTH0_CLIENT_ID},
}.Encode(),
}
}
func (a *Authenticator) RefreshAccessToken(
ctx context.Context,
oldAccessToken string,
) (
accessToken string,
expiration time.Time,
err error,
) {
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, accessToken)
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err)
}
tkn, err := a.TokenSource(ctx, &oauth2.Token{
// AccessToken is the token that authorizes and authenticates
// the requests.
AccessToken: oldAccessToken,
// TokenType is the type of token.
// The Type method returns either this or "Bearer", the default.
TokenType: tokenType,
// RefreshToken is a token that's used by the application
// (as opposed to the user) to refresh the access token
// if it expires.
RefreshToken: refreshToken,
/*
// Expiry is the optional expiration time of the access token.
//
// If zero, TokenSource implementations will reuse the same
// token forever and RefreshToken or equivalent
// mechanisms for that TokenSource will not be used.
Expiry time.Time `json:"expiry,omitempty"`
*/
}).Token()
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to fetch refresh token: %w", err)
}
idToken, claims, err := a.verifyIDTokenAndClaimsFromToken(ctx, tkn)
if err != nil {
return "", time.Time{}, err
}
if _, err = a.db.Exec(
ctx,
`
WITH deleted_tokens AS (
DELETE FROM
oauth_tokens
WHERE
access_token = @old_access_token
RETURNING
access_token AS old_access_token
), new_tokens AS (
INSERT INTO oauth_tokens (
access_token,
token_type,
refresh_token,
expiry,
id_token_issuer,
id_token_audience,
id_token_subject,
id_token_expiry,
id_token_issued_at,
id_token_nonce,
id_token_access_token_hash,
id_token_custom_claims_family_name,
id_token_custom_claims_given_name,
id_token_custom_claims_name,
id_token_custom_claims_nickname,
id_token_custom_claims_picture,
id_token_custom_claims_updated_at
)
VALUES (
@new_access_token,
@token_type,
@refresh_token,
@expiry,
@id_token_issuer,
@id_token_audience,
@id_token_subject,
@id_token_expiry,
@id_token_issued_at,
@id_token_nonce,
@id_token_access_token_hash,
@id_token_custom_claims_family_name,
@id_token_custom_claims_given_name,
@id_token_custom_claims_name,
@id_token_custom_claims_nickname,
@id_token_custom_claims_picture,
@id_token_custom_claims_updated_at
)
RETURNING
access_token AS new_access_token
)
SELECT
old_access_token,
new_access_token
FROM
deleted_tokens
FULL JOIN
new_tokens
`,
pgx.NamedArgs{
"old_access_token": oldAccessToken,
"new_access_token": tkn.AccessToken,
"token_type": tkn.TokenType,
"refresh_token": tkn.RefreshToken,
"expiry": tkn.Expiry,
"id_token_issuer": idToken.Issuer,
"id_token_audience": pgtype.FlatArray[string](idToken.Audience),
"id_token_subject": idToken.Subject,
"id_token_expiry": idToken.Expiry,
"id_token_issued_at": idToken.IssuedAt,
"id_token_nonce": idToken.Nonce,
"id_token_access_token_hash": idToken.AccessTokenHash,
"id_token_custom_claims_family_name": claims.FamilyName,
"id_token_custom_claims_given_name": claims.GivenName,
"id_token_custom_claims_name": claims.Name,
"id_token_custom_claims_nickname": claims.Nickname,
"id_token_custom_claims_picture": claims.Picture,
"id_token_custom_claims_updated_at": claims.UpdatedAt,
},
); err != nil {
return "", time.Time{}, fmt.Errorf("failed to save new access token and delete old access token: %w", err)
}
return tkn.AccessToken, tkn.Expiry.UTC(), nil
}
func (a *Authenticator) verifyIDTokenAndClaimsFromToken(ctx context.Context, tkn *oauth2.Token) (idToken *oidc.IDToken, claims *AccessTokenClaims, err error) {
if idToken, err = a.VerifyIDToken(ctx, tkn); err != nil {
return nil, nil, fmt.Errorf("failed to verify id Token: %w", err)
}
claims = new(AccessTokenClaims)
if err := idToken.Claims(claims); err != nil {
return nil, nil, fmt.Errorf("failed to obtain id token claims: %w", err)
}
return idToken, claims, nil
}
+190
View File
@@ -0,0 +1,190 @@
package authentication
import (
"context"
"crypto/rand"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"ruben/inventory2/consts"
)
// NewState creates a new state for logging in, saving it in the database.
func (a *Authenticator) NewState(ctx context.Context, targetURI string) ([32]byte, error) {
state, err := generateRandomState()
if err != nil {
return state, fmt.Errorf("failed to generate random state: %w", err)
}
if _, err = a.db.Exec(
ctx,
`
INSERT INTO
oauth_login_states (
state,
target_uri
)
VALUES (
@state,
@target_uri
)
`,
pgx.NamedArgs{
"state": state[:],
"target_uri": targetURI,
},
); err != nil {
return state, fmt.Errorf("failed to execute query: %w", err)
}
return state, nil
}
func generateRandomState() ([32]byte, error) {
var b [32]byte
_, err := rand.Read(b[:])
return b, err
}
// GetStateExpirationAndURL get's the oauth state's expiration
func (a *Authenticator) GetStateExpirationAndURL(ctx context.Context, state string) (time.Time, string, error) {
rows, err := a.db.Query(
ctx,
`
SELECT
expiration,
target_uri
FROM
oauth_login_states
WHERE
state = ('\x' || @state)::BYTEA`,
pgx.NamedArgs{
"state": state,
},
)
if err != nil {
return time.Time{}, "", fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Expiration time.Time
Target_uri pgtype.Text
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, "", consts.ErrNotFound
}
return time.Time{}, "", fmt.Errorf("failed to scan row: %w", err)
}
return r.Expiration, r.Target_uri.String, nil
}
// TODO: need to automatically clean up expired tokens
func (a *Authenticator) DeleteOAuthTokens(ctx context.Context, accessToken string) error {
_, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE access_token = @access_token", pgx.NamedArgs{"access_token": accessToken})
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
func (a *Authenticator) GetAccessTokenClaimsAndExpiration(ctx context.Context, accessToken string) (claims AccessTokenClaims, err error) {
rows, err := a.db.Query(
ctx,
`
SELECT
expiry,
id_token_custom_claims_name,
id_token_custom_claims_picture,
id_token_custom_claims_nickname,
id_token_custom_claims_given_name,
id_token_custom_claims_family_name,
id_token_custom_claims_updated_at
FROM
oauth_tokens
WHERE
access_token = @access_token
`,
pgx.NamedArgs{
"access_token": accessToken,
},
)
if err != nil {
return AccessTokenClaims{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Expiry time.Time
Id_token_custom_claims_name string
Id_token_custom_claims_picture string
Id_token_custom_claims_nickname string
Id_token_custom_claims_given_name string
Id_token_custom_claims_family_name string
Id_token_custom_claims_updated_at time.Time
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AccessTokenClaims{}, consts.ErrNotFound
}
return AccessTokenClaims{}, fmt.Errorf("failed to scan row: %w", err)
}
claims.Expires = r.Expiry.Unix()
claims.Expiration = r.Expiry
claims.Name = r.Id_token_custom_claims_name
claims.Picture = r.Id_token_custom_claims_picture
claims.Nickname = r.Id_token_custom_claims_nickname
claims.GivenName = r.Id_token_custom_claims_given_name
claims.FamilyName = r.Id_token_custom_claims_family_name
claims.UpdatedAt = r.Id_token_custom_claims_updated_at
return claims, nil
}
func (a *Authenticator) getRefreshTokenForAccessToken(ctx context.Context, accessToken string) (refreshToken, tokenType string, err error) {
rows, err := a.db.Query(
ctx,
`
SELECT
refresh_token,
token_type
FROM
oauth_tokens
WHERE
access_token = @access_token
`,
pgx.NamedArgs{
"access_token": accessToken,
},
)
if err != nil {
return "", "", fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Refresh_token string
Token_type string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", consts.ErrNotFound
}
return "", "", fmt.Errorf("failed to scan row: %w", err)
}
return r.Refresh_token, r.Token_type, nil
}
+47
View File
@@ -0,0 +1,47 @@
package etsy
import (
"context"
"fmt"
"net/http"
"ruben/inventory2/domains/platforms/etsy/generated_client"
)
func newFixedAccessTokenClient(apiKey, accessToken string) (*generated_client.ClientWithResponses, error) {
return newAuthenticatingClient(apiKey, func(ctx context.Context) (string, error) {
return accessToken, nil
})
}
func newAccessTokenRefreshingClient(apiKey string) (*generated_client.ClientWithResponses, error) {
return newAuthenticatingClient(apiKey, func(ctx context.Context) (string, error) {
// TODO: look up the token in the db.
return "", nil
})
}
func newAuthenticatingClient(apiKey string, getAccessToken func(context.Context) (string, error)) (*generated_client.ClientWithResponses, error) {
var setAuthHeaders generated_client.ClientOption = func(c *generated_client.Client) error {
c.RequestEditors = append(c.RequestEditors, generated_client.RequestEditorFn(func(ctx context.Context, req *http.Request) error {
accessToken, err := getAccessToken(req.Context())
if err != nil {
return err
}
h := req.Header
h.Set("x-api-key", apiKey)
h.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
return nil
}))
return nil
}
c, err := generated_client.NewClientWithResponses("https://api.etsy.com", setAuthHeaders)
if err != nil {
return nil, fmt.Errorf("failed to initialize open api client: %w", err)
}
return c, nil
}
+308
View File
@@ -0,0 +1,308 @@
package etsy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"ruben/inventory2/domains/platforms/etsy/generated_client"
"ruben/inventory2/logging"
)
//go:generate oapi-codegen -generate types,client -package generated_client -o generated_client/client.go openapi.3.0.2.json
//go:generate concurry -s Platform
// TODO:
// - [x] document the flow in the README.md
// - [ ] save the initial access/refresh tokens in the db
// - [ ] make calls to refresh the access token and store it in the db.
// - [ ] make cron jobs to automatically refresh the refresh token upon as tokens approach expiration.
// - [ ] make pages to direct to the etsy shop acceptance url/page
// - [ ] stub an account page
type (
Platform struct {
log *logging.Logger
oAuthRedirectURI func(acctID int64) string
apiKeystring string
apiSharedSecret string
db *pgxpool.Pool
}
)
// oauth scopes
const (
scopeAddressRead = "address_r" // Read a member's shipping addresses.
scopeAddressWrite = "address_w" // Update and delete a member's shipping address.
scopeBillingRead = "billing_r" // Read a member's Etsy bill charges and payments.
scopeCartRead = "cart_r" // Read the contents of a members cart.
scopeCartWrite = "cart_w" // Add and remove listings from a member's cart.
scopeEmailRead = "email_r" // Read a user profile
scopeFavoritesRead = "favorites_r" // View a member's favorite listings and users.
scopeFavoritesWrite = "favorites_w" // Add to and remove from a member's favorite listings and users.
scopeFeedbackRead = "feedback_r" // View all details of a member's feedback (including purchase history.)
scopeListings_d = "listings_d" // Delete a member's listings.
scopeListingsRead = "listings_r" // Read a member's inactive and expired (i.e., non-public) listings.
scopeListingsWrite = "listings_w" // Create and edit a member's listings.
scopeProfileRead = "profile_r" // Read a member's private profile information.
scopeProfileWrite = "profile_w" // Update a member's private profile information.
scopeRecommendRead = "recommend_r" // View a member's recommended listings.
scopeRecommendWrite = "recommend_w" // Remove a member's recommended listings.
scopeShopsRead = "shops_r" // See a member's shop description, messages and sections, even if not (yet) public.
scopeShopsWrite = "shops_w" // Update a member's shop description, messages and sections.
scopeTransactionsRead = "transactions_r" // Read a member's purchase and sales data. This applies to buyers as well as sellers.
scopeTransactionsWrite = "transactions_w" // Update a member's sales data.
)
func NewPlatform(logger *logging.Logger, oAuthRedirectURI func(acctID int64) string, apiKeystring, apiSharedSecret string, db *pgxpool.Pool) *Platform {
return &Platform{
log: logger,
oAuthRedirectURI: oAuthRedirectURI,
apiKeystring: apiKeystring,
apiSharedSecret: apiSharedSecret,
db: db,
}
}
func (p *Platform) WithContext(ctx context.Context) *PlatformWithContext {
return NewPlatformWithContext(ctx, p)
}
func (p *Platform) GenerateConnectionURLForNewAccount(ctx context.Context, acctID int64) (*url.URL, error) {
req, err := p.createNewOAuthRequest(ctx, acctID)
if err != nil {
return nil, fmt.Errorf("failed to create oauth request parameters: %w", err)
}
state := req.state
code := req.pkceCode
return &url.URL{
Scheme: "https",
Host: "www.etsy.com",
Path: "/oauth/connect",
RawQuery: url.Values{
"response_type": {"code"},
"redirect_uri": {p.oAuthRedirectURI(acctID)},
"scope": {url.QueryEscape(strings.Join([]string{
scopeCartRead,
scopeCartWrite,
scopeEmailRead,
scopeListingsWrite,
}, ","))},
"client_id": {p.apiKeystring},
"state": {state.String()},
"code_challenge": {fmt.Sprintf("%x", code.challenge)},
"code_challenge_method": {"S256"},
}.Encode(),
}, nil
}
// HandleNewAuthCode handles the auth code to get api access
func (p *Platform) HandleNewAuthCode(ctx context.Context, acctID int64, state, authCode string) (bool, error) {
// look up matching oauth request
stateUUID, err := uuid.Parse(state)
if err != nil {
return false, nil
}
oar, ok, err := p.getOauthRequest(ctx, stateUUID)
if err != nil {
return false, fmt.Errorf("failed to look up existing oauth request: %w", err)
}
if !ok {
return false, nil
}
if oar.acctID != acctID {
return false, p.deleteOauthRequest(ctx, stateUUID)
}
// construct http request to obtain access token
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
(&url.URL{
Scheme: "https://",
Host: "api.etsy.com",
Path: "/v3/public/oauth/token",
}).String(),
bytes.NewReader([]byte(url.Values{
"grant_type": {"authorization_code"},
"client_id": {p.apiKeystring},
"redirect_uri": {p.oAuthRedirectURI(acctID)},
"code": {authCode},
"code_verifier": {fmt.Sprintf("%x", oar.pkceCode.verifier)},
}.Encode())),
)
if err != nil {
return false, fmt.Errorf("failed to generate http request to get oauth tokens: %w", err)
}
req.Header.Set("Content-Type", "x-www-form-urlencoded")
// perform request to obtain access token
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, fmt.Errorf("failed to perform http request: %w", err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil && !errors.Is(err, io.EOF) {
return false, fmt.Errorf("failed to read response body: %w", err)
}
if c := resp.StatusCode; (c / 100) != 2 {
return false, fmt.Errorf("unexpected http response code: %d; body = %s", c, string(b))
}
// parse and validate response body
accessToken, refreshToken, expiration, userID, err := p.parseAccessCodeResponseBody(ctx, b)
if err != nil {
return false, fmt.Errorf("failed to parse response body: %w", err)
}
// look up user's shop id
shopID, err := p.getNewUserShopID(ctx, accessToken, userID)
if err != nil {
return false, fmt.Errorf("failed to complete sign on due to failing to look up the user's shop id: %w", err)
}
// save all to database
const ninetyDays = 90 * 24 * time.Hour
if err := p.saveNewEtsyUser(
ctx,
EtsyUser{
AcctID: acctID,
UserID: userID,
ShopID: shopID,
},
etsyAccessTokens{
access: tokenAndExpiration{
token: accessToken,
expiration: expiration,
},
refresh: tokenAndExpiration{
token: refreshToken,
expiration: time.Now().Add(ninetyDays),
},
},
); err != nil {
return false, fmt.Errorf("failed to save new user and access token: %w", err)
}
return true, nil
}
func (p *Platform) parseAccessCodeResponseBody(ctx context.Context, body []byte) (
accessToken string,
refreshToken string,
expiration time.Time,
userID int64, // stored in the access token & refresh token
err error,
) {
// parse and validate response body
var (
tokenType string
expirationInSeconds int
)
if err = json.Unmarshal(body, &struct {
Access_Token *string
Token_Type *string
Expires_In *int
Refresh_Token *string
}{
Access_Token: &accessToken,
Token_Type: &tokenType,
Expires_In: &expirationInSeconds,
Refresh_Token: &refreshToken,
}); err != nil {
err = fmt.Errorf("failed to decode request body as json: %w", err)
return
}
if v := accessToken; v == "" {
err = fmt.Errorf("no access token specified in access token response: body = %s", string(body))
return
} else if accessTokenParts := strings.SplitN(accessToken, ".", 2); len(accessTokenParts) < 2 {
err = fmt.Errorf("unexpected access token format: expected a user_id prefix: <user_id>.<remaindeder>: %s: body = %s", accessToken, string(body))
return
} else if i, ierr := strconv.ParseInt(accessTokenParts[0], 10, 64); ierr != nil {
err = fmt.Errorf("unexpected user_id in access token: should be an integer: %s: %w", accessTokenParts[0], ierr)
return
} else if i <= 0 {
err = fmt.Errorf("unexpected user_id in access token: should be a positive integer: %d: %w", i, err)
} else {
userID = i
}
if tt := tokenType; tt == "" {
err = fmt.Errorf("no token type specified in access token response: body = %s", string(body))
return
} else if tt != "Bearer" {
err = fmt.Errorf("unexpected token type specified in access token response: %s: body = %s", tt, string(body))
return
}
if v := expirationInSeconds; v == 0 {
err = fmt.Errorf("no expiration specified in access token response: body = %s", string(body))
return
} else if v < 0 {
err = fmt.Errorf("unexpected expiration specified in access token response: %d: body = %s", v, string(body))
return
} else {
expiration = time.Now().UTC().Add(time.Duration(expirationInSeconds) * time.Second)
}
if v := refreshToken; v == "" {
err = fmt.Errorf("no refresh token specified in access token response: body = %s", string(body))
return
}
return
}
func (p *Platform) getNewUserShopID(ctx context.Context, accessToken string, userID int64) (int64, error) {
cli, err := newFixedAccessTokenClient(p.apiKeystring, accessToken)
if err != nil {
return 0, fmt.Errorf("failed to initialize openapi client: %w", err)
}
var res *generated_client.GetShopByOwnerUserIdResponse
if res, err = cli.GetShopByOwnerUserIdWithResponse(ctx, userID); err != nil {
return 0, fmt.Errorf("failed to obtain shop id due to failure to initialize request to obtain shop id: %w", err)
} else if res.JSON400 != nil {
return 0, fmt.Errorf("failed to look up shop for user %d due to 400 error: %s", userID, res.JSON400.Error)
} else if res.JSON403 != nil {
return 0, fmt.Errorf("failed to look up shop for user %d due to 403 error: %s", userID, res.JSON403.Error)
} else if res.JSON404 != nil {
return 0, fmt.Errorf("failed to look up shop for user %d due to 404 error: %s", userID, res.JSON404.Error)
} else if res.JSON500 != nil {
return 0, fmt.Errorf("failed to look up shop for user %d due to 500 error: %s", userID, res.JSON500.Error)
}
if res.JSON200.ShopId == nil {
return 0, fmt.Errorf("shop for user %d has no shop_id set: body = %s", userID, res.Body)
}
return *res.JSON200.ShopId, nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package etsy
import (
"context"
"github.com/google/uuid"
"net/url"
"time"
)
type PlatformWithContext struct {
ctx context.Context
*Platform
}
func NewPlatformWithContext(ctx context.Context, v *Platform) *PlatformWithContext {
return &PlatformWithContext{
ctx: ctx,
Platform: v,
}
}
func (v_ctx *PlatformWithContext) GenerateConnectionURLForNewAccount(acctID int64) (*url.URL, error) {
return v_ctx.Platform.GenerateConnectionURLForNewAccount(v_ctx.ctx, acctID)
}
func (v_ctx *PlatformWithContext) HandleNewAuthCode(acctID int64, state string, authCode string) (bool, error) {
return v_ctx.Platform.HandleNewAuthCode(v_ctx.ctx, acctID, state, authCode)
}
func (v_ctx *PlatformWithContext) parseAccessCodeResponseBody(body []byte) (string, string, time.Time, int64, error) {
return v_ctx.Platform.parseAccessCodeResponseBody(v_ctx.ctx, body)
}
func (v_ctx *PlatformWithContext) getNewUserShopID(accessToken string, userID int64) (int64, error) {
return v_ctx.Platform.getNewUserShopID(v_ctx.ctx, accessToken, userID)
}
func (v_ctx *PlatformWithContext) GetUserPointerByAccountID(acctID int64) (*EtsyUser, error) {
return v_ctx.Platform.GetUserPointerByAccountID(v_ctx.ctx, acctID)
}
func (v_ctx *PlatformWithContext) saveNewEtsyUser(user EtsyUser, tokens etsyAccessTokens) error {
return v_ctx.Platform.saveNewEtsyUser(v_ctx.ctx, user, tokens)
}
func (v_ctx *PlatformWithContext) createNewOAuthRequest(acctID int64) (oauth2Request, error) {
return v_ctx.Platform.createNewOAuthRequest(v_ctx.ctx, acctID)
}
func (v_ctx *PlatformWithContext) getOauthRequest(state uuid.UUID) (oauth2Request, bool, error) {
return v_ctx.Platform.getOauthRequest(v_ctx.ctx, state)
}
func (v_ctx *PlatformWithContext) InvalidateState(state string) error {
return v_ctx.Platform.InvalidateState(v_ctx.ctx, state)
}
func (v_ctx *PlatformWithContext) deleteOauthRequest(state uuid.UUID) error {
return v_ctx.Platform.deleteOauthRequest(v_ctx.ctx, state)
}
+269
View File
@@ -0,0 +1,269 @@
package etsy
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type (
EtsyUser struct {
AcctID int64
UserID int64
ShopID int64
}
etsyAccessTokens struct {
access tokenAndExpiration
refresh tokenAndExpiration
}
tokenAndExpiration struct {
token string
expiration time.Time
}
oauth2Request struct {
acctID int64
state uuid.UUID
expiration time.Time
pkceCode pkceCode
}
pkceCode struct {
verifier [32]byte
challenge []byte
}
)
func (p *Platform) GetUserPointerByAccountID(ctx context.Context, acctID int64) (*EtsyUser, error) {
rows, err := p.db.Query(
ctx,
"SELECT user_id, shop_id FROM etsy_users 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 {
User_ID int64
Shop_ID int64
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to scan row: %w", err)
}
return &EtsyUser{
AcctID: acctID,
UserID: r.User_ID,
ShopID: r.Shop_ID,
}, nil
}
func (p *Platform) saveNewEtsyUser(
ctx context.Context,
user EtsyUser,
tokens etsyAccessTokens,
) (err error) {
_, err = p.db.Exec(
ctx,
`
WITH new_user (
INSERT INTO etsy_users (
account_id,
user_id,
shop_id
)
VALUES (
@account_id,
@user_id,
@shop_id
)
RETURNING
account_id,
user_id,
shop_id
)
INSERT INTO etsy_access_tokens (
access_token,
refresh_token,
access_token_expiration,
refresh_token_expiration
)
VALUE (
@access_token,
@refresh_token,
@access_token_expiration,
@refresh_token_expiration
)
`,
pgx.NamedArgs{
"account_id": user.AcctID,
"user_id": user.UserID,
"shop_id": user.ShopID,
"access_token": tokens.access.token,
"refresh_token": tokens.refresh.token,
"access_token_expiration": tokens.access.expiration,
"refresh_token_expiration": tokens.refresh.expiration,
},
)
if err != nil {
return fmt.Errorf("failed to insert new records: %w", err)
}
return nil
}
// TODO: clean these up on timer.
func (p *Platform) createNewOAuthRequest(ctx context.Context, acctID int64) (oauth2Request, error) {
req := oauth2Request{
acctID: acctID,
state: uuid.New(),
expiration: time.Now().UTC().Add(10 * time.Minute),
pkceCode: newPKCECode(),
}
_, err := p.db.Exec(
ctx,
`
INSERT INTO etsy_oauth_requests (
account_id,
state,
code_verifier,
expiration
)
VALUES (
@account_id,
@state,
@code_verifier,
@expiration
)
`,
pgx.NamedArgs{
"account_id": req.acctID,
"state": req.state[:],
"code_verifier": req.pkceCode.verifier[:],
"expiration": req.expiration,
},
)
if err != nil {
return oauth2Request{}, fmt.Errorf("failed to insert record: %w", err)
}
return req, nil
}
func (p *Platform) getOauthRequest(ctx context.Context, state uuid.UUID) (req oauth2Request, ok bool, err error) {
stateBytes := [16]byte(state)
rows, err := p.db.Query(
ctx,
`
SELECT
account_id,
code_verifier,
expiration
FROM
etsy_oauth_requests
WHERE
state = @state
`,
pgx.NamedArgs{
"state": stateBytes[:],
},
)
if err != nil {
return oauth2Request{}, false, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Account_ID int64
Code_Verifier []byte
Expiration time.Time
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return oauth2Request{}, false, nil
}
}
if l := len(r.Code_Verifier); l != 32 {
return oauth2Request{}, false, fmt.Errorf("code_verifier of unexpected length found: expected = 32, found = %d", l)
}
var verifier [32]byte
copy(verifier[:], r.Code_Verifier)
return oauth2Request{
acctID: r.Account_ID,
state: state,
expiration: r.Expiration.UTC(),
pkceCode: pkceCode{
verifier: verifier,
challenge: generateCodeChallenge(verifier),
},
}, false, nil
}
func (p *Platform) InvalidateState(ctx context.Context, state string) error {
stateUUID, err := uuid.Parse(state)
if err != nil {
return nil
}
return p.deleteOauthRequest(ctx, stateUUID)
}
func (p *Platform) deleteOauthRequest(ctx context.Context, state uuid.UUID) error {
_, err := p.db.Exec(
ctx,
`
DELETE FROM etsy_oauth_requests
WHERE state = @state
`,
pgx.NamedArgs{
"state": state,
},
)
if err != nil {
return fmt.Errorf("failed to execute query: %w", err)
}
return nil
}
func newPKCECode() pkceCode {
var code pkceCode
part1 := [16]byte(uuid.New())
part2 := [16]byte(uuid.New())
copy(code.verifier[0:16], part1[:])
copy(code.verifier[16:32], part2[:])
code.challenge = generateCodeChallenge(code.verifier)
return code
}
func generateCodeChallenge(codeVerifier [32]byte) []byte {
return generateSHA256Hash(codeVerifier[:])
}
func generateSHA256Hash(b []byte) []byte {
h := sha256.New()
h.Write(b)
return h.Sum(nil)
}
+118
View File
@@ -0,0 +1,118 @@
package raw_events
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"ruben/inventory2/logging"
)
type (
Store struct {
log *logging.Logger
db *pgxpool.Pool
}
Event struct {
Platform string
StoreID string
EventID string
EventTimestamp time.Time
Payload json.RawMessage
}
)
func NewStore(logger *logging.Logger, db *pgxpool.Pool) *Store {
return &Store{
log: logger,
db: db,
}
}
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
return NewStoreWithContext(ctx, db)
}
func (db *Store) Save(ctx context.Context, e *Event) error {
_, err := db.db.Exec(
ctx,
`
INSERT INTO raw_store_events (
platform,
store_id,
event_timestamp,
event_id,
raw_payload
)
VALUES (
@platform,
@store_id,
@event_timestamp,
@event_id,
@raw_payload
)
`,
pgx.NamedArgs{
"platform": e.Platform,
"store_id": e.StoreID,
"event_timestamp": pgtype.Timestamptz{
Time: e.EventTimestamp,
Valid: !e.EventTimestamp.IsZero(),
},
"event_id": e.EventID,
"raw_payload": string(e.Payload),
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
func (db *Store) LoadEventsForStore(
ctx context.Context,
platform string,
storeID string,
) ([]Event, error) {
rows, err := db.db.Query(
ctx,
`
SELECT
platform as Platform,
store_id as StoreID,
event_timestamp as EventTimestamp,
event_id as EventID,
raw_payload as Payload
FROM
raw_store_events
WHERE
platform = @platform
AND store_id = @store_id
ORDER BY
event_timestamp DESC
LIMIT
100
`,
pgx.NamedArgs{
"platform": platform,
"store_id": storeID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
evts, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[Event])
if err != nil {
return nil, fmt.Errorf("failed to scan events: %w", err)
}
return evts, nil
}
+28
View File
@@ -0,0 +1,28 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package raw_events
import (
"context"
)
type StoreWithContext struct {
ctx context.Context
*Store
}
func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
Store: v,
}
}
func (v_ctx *StoreWithContext) Save(e *Event) error {
return v_ctx.Store.Save(v_ctx.ctx, e)
}
func (v_ctx *StoreWithContext) LoadEventsForStore(platform string, storeID string) ([]Event, error) {
return v_ctx.Store.LoadEventsForStore(v_ctx.ctx, platform, storeID)
}