removed intermediate /internal directory
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
package consts
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
)
|
||||
@@ -1,423 +0,0 @@
|
||||
package accounts
|
||||
|
||||
// to generate StoreWithContext
|
||||
//go:generate concurry -s Store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"ruben/inventory2/internal/consts"
|
||||
"ruben/inventory2/internal/logging"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"ruben/inventory2/internal/consts"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
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,
|
||||
) (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
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,819 +0,0 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"ruben/inventory2/internal/consts"
|
||||
"slices"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
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/internal/consts"
|
||||
"ruben/inventory2/internal/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
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"ruben/inventory2/internal/consts"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package etsy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"ruben/inventory2/internal/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
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
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/internal/domains/platforms/etsy/generated_client"
|
||||
"ruben/inventory2/internal/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 member’s 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
@@ -1,63 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package raw_events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Logger creates a *slog.Logger wrap with a few more methods wrapped on top.
|
||||
// It recreates a number of methods to allow replacing a *slog.Logger functionally.
|
||||
// It also implements a number of methods to support formatted messages and
|
||||
// tracing.
|
||||
type Logger struct {
|
||||
*slog.Logger
|
||||
}
|
||||
|
||||
func New(h slog.Handler) *Logger {
|
||||
return &Logger{
|
||||
Logger: slog.New(h),
|
||||
}
|
||||
}
|
||||
|
||||
func From(l *slog.Logger) *Logger {
|
||||
return &Logger{
|
||||
Logger: l,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) With(args ...any) *Logger {
|
||||
return From(l.Logger.With(args...))
|
||||
}
|
||||
|
||||
func (l *Logger) WithGroup(name string) *Logger {
|
||||
return From(l.Logger.WithGroup(name))
|
||||
}
|
||||
|
||||
func (l *Logger) Debugf(format string, args ...any) {
|
||||
l.logContextf(context.Background(), slog.LevelDebug, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) DebugContextf(ctx context.Context, format string, args ...any) {
|
||||
l.logContextf(ctx, slog.LevelDebug, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) DebugDeferf(format string, args ...any) func(func() (string, []any)) {
|
||||
return l.logContextDeferf(context.Background(), slog.LevelDebug, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) DebugContextDeferf(ctx context.Context, format string, args ...any) func(func() (string, []any)) {
|
||||
return l.logContextDeferf(ctx, slog.LevelDebug, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) DebugCallf(method string) func() {
|
||||
return l.logContextCallf(context.Background(), slog.LevelDebug, method)
|
||||
}
|
||||
|
||||
func (l *Logger) DebugContextCallf(ctx context.Context, method string) func() {
|
||||
return l.logContextCallf(ctx, slog.LevelDebug, method)
|
||||
}
|
||||
|
||||
func (l *Logger) Errorf(format string, args ...any) {
|
||||
l.logContextf(context.Background(), slog.LevelError, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) ErrorContextf(ctx context.Context, format string, args ...any) {
|
||||
l.logContextf(ctx, slog.LevelError, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) ErrorDeferf(format string, args ...any) func(func() (string, []any)) {
|
||||
return l.logContextDeferf(context.Background(), slog.LevelError, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) ErrorContextDeferf(ctx context.Context, format string, args ...any) func(func() (string, []any)) {
|
||||
return l.logContextDeferf(ctx, slog.LevelError, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) ErrorCallf(method string) func() {
|
||||
return l.logContextCallf(context.Background(), slog.LevelError, method)
|
||||
}
|
||||
|
||||
func (l *Logger) ErrorContextCallf(ctx context.Context, method string) func() {
|
||||
return l.logContextCallf(ctx, slog.LevelError, method)
|
||||
}
|
||||
|
||||
func (l *Logger) Infof(format string, args ...any) {
|
||||
l.logContextf(context.Background(), slog.LevelInfo, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) InfoContextf(ctx context.Context, format string, args ...any) {
|
||||
l.logContextf(ctx, slog.LevelInfo, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) InfoDeferf(format string, args ...any) func(func() (string, []any)) {
|
||||
return l.logContextDeferf(context.Background(), slog.LevelInfo, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) InfoContextDeferf(ctx context.Context, format string, args ...any) func(func() (string, []any)) {
|
||||
return l.logContextDeferf(ctx, slog.LevelInfo, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) InfoCallf(method string) func() {
|
||||
return l.logContextCallf(context.Background(), slog.LevelInfo, method)
|
||||
}
|
||||
|
||||
func (l *Logger) InfoContextCallf(ctx context.Context, method string) func() {
|
||||
return l.logContextCallf(ctx, slog.LevelInfo, method)
|
||||
}
|
||||
|
||||
func (l *Logger) Warnf(format string, args ...any) {
|
||||
l.logContextf(context.Background(), slog.LevelWarn, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) WarnContextf(ctx context.Context, format string, args ...any) {
|
||||
l.logContextf(ctx, slog.LevelWarn, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) WarnDeferf(format string, args ...any) func(func() (string, []any)) {
|
||||
return l.logContextDeferf(context.Background(), slog.LevelWarn, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) WarnContextDeferf(ctx context.Context, format string, args ...any) func(func() (string, []any)) {
|
||||
return l.logContextDeferf(ctx, slog.LevelWarn, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) WarnCallf(method string) func() {
|
||||
return l.logContextCallf(context.Background(), slog.LevelWarn, method)
|
||||
}
|
||||
|
||||
func (l *Logger) WarnContextCallf(ctx context.Context, method string) func() {
|
||||
return l.logContextCallf(ctx, slog.LevelWarn, method)
|
||||
}
|
||||
|
||||
func (l *Logger) logContextf(ctx context.Context, lvl slog.Level, format string, args ...any) {
|
||||
if !l.Enabled(ctx, slog.LevelInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
var pcs [1]uintptr
|
||||
runtime.Callers(3, pcs[:]) // skip [Callers, Infof]
|
||||
|
||||
_ = l.Handler().Handle(
|
||||
ctx,
|
||||
slog.NewRecord(time.Now(), lvl, fmt.Sprintf(format, args...), pcs[0]),
|
||||
)
|
||||
}
|
||||
|
||||
func (l *Logger) logContextCallf(ctx context.Context, lvl slog.Level, method string) func() {
|
||||
fn := l.With("method", method).logContextDeferf(ctx, lvl, "%s called", method)
|
||||
return func() {
|
||||
fn(func() (string, []any) {
|
||||
return "%s returned", []any{method}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) logContextDeferf(ctx context.Context, lvl slog.Level, format string, args ...any) func(func() (msg string, args []any)) {
|
||||
if !l.Enabled(ctx, slog.LevelInfo) {
|
||||
return func(func() (string, []any)) {
|
||||
}
|
||||
}
|
||||
|
||||
var pcs [1]uintptr
|
||||
runtime.Callers(3, pcs[:]) // skip [Callers, Infof]
|
||||
pc := pcs[0]
|
||||
|
||||
_ = l.Handler().Handle(ctx, slog.NewRecord(time.Now(), lvl, fmt.Sprintf(format, args...), pc))
|
||||
return func(deferred func() (newFormat string, moreArgs []any)) {
|
||||
if !l.Enabled(ctx, slog.LevelInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
if deferred != nil {
|
||||
format, args = deferred()
|
||||
}
|
||||
|
||||
_ = l.Handler().Handle(ctx, slog.NewRecord(time.Now(), lvl, fmt.Sprintf(format, args...), pc))
|
||||
}
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"ruben/inventory2/internal/consts"
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/auth"
|
||||
"ruben/inventory2/internal/server/param"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
"ruben/inventory2/internal/server/sse"
|
||||
)
|
||||
|
||||
type accountSubrouter struct {
|
||||
log *logging.Logger
|
||||
accts *accounts.Store
|
||||
pub *sse.UpdateNotificationPublisher
|
||||
}
|
||||
|
||||
func Routes(
|
||||
r *gin.RouterGroup,
|
||||
logger *logging.Logger,
|
||||
accts *accounts.Store,
|
||||
pub *sse.UpdateNotificationPublisher,
|
||||
) {
|
||||
as := &accountSubrouter{
|
||||
log: logger,
|
||||
accts: accts,
|
||||
pub: pub,
|
||||
}
|
||||
|
||||
r.POST("", response.Handler(as.createAccount))
|
||||
|
||||
platformGroup := r.Group("/:acctID/platforms/:platform", pub.Publish("/:acctID/platforms"))
|
||||
platformGroup.PUT("/order-index", response.Handler(as.setOrderOfPlatformOnAccountPage))
|
||||
platformGroup.POST("/shops/mocks", response.Handler(as.createMockShop))
|
||||
|
||||
mockShops := platformGroup.Group("/shops/mocks/:shop-id")
|
||||
mockShops.POST("/listings", response.Handler(as.addMockListing))
|
||||
mockShops.PUT("/listings/:listing-id", response.Handler(as.updateMockListing))
|
||||
mockShops.DELETE("/listings/:listing-id", response.Handler(as.deleteMockListing))
|
||||
|
||||
syncGroups := r.Group("/:acctID/inventory/sync-groups")
|
||||
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))
|
||||
draftListings.PUT("/:orderIndex/shop", response.Handler(as.setShopInSyncGroupListingDraft))
|
||||
draftListings.PUT("/:orderIndex/listing", response.Handler(as.setListingInSyncGroupListingDraft))
|
||||
draftListings.DELETE("/:orderIndex", response.Handler(as.deleteSyncGroupListingDraft))
|
||||
|
||||
mockSyncGroups := syncGroups.Group("/mock")
|
||||
mockDraftListings := mockSyncGroups.Group("/draft/listings", pub.Publish("/:acctID/inventory/sync-groups/mock/draft/listings"))
|
||||
mockDraftListings.POST("", response.Handler(as.createMockSyncGroupListingDraft))
|
||||
mockDraftListings.PUT("/:orderIndex/shop", response.Handler(as.setShopInMockSyncGroupListingDraft))
|
||||
mockDraftListings.PUT("/:orderIndex/listing", response.Handler(as.setListingInMockSyncGroupListingDraft))
|
||||
mockDraftListings.DELETE("/:orderIndex", response.Handler(as.deleteMockSyncGroupListingDraft))
|
||||
}
|
||||
|
||||
// POST /
|
||||
func (s *accountSubrouter) createAccount(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
|
||||
// TODO: get email from the user's account profile (how?) - will need to flesh out the account creation process later on
|
||||
email := uuid.NewString() + "@example.com"
|
||||
|
||||
userID := auth.GetIdentity(ctx).User.UserID
|
||||
|
||||
_, err := s.accts.CreateAccount(ctx, userID, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, consts.ErrConflict) {
|
||||
return nil, response.Conflict().
|
||||
Msg("user already has an account")
|
||||
}
|
||||
return nil, response.Errorf("failed to create account: %w", err)
|
||||
}
|
||||
|
||||
return response.StatusCreated().
|
||||
// force a reload to ensure an sse connection is made.
|
||||
// user should automatically be redirected to the appropriate page based on their current location and/or login/account statuses.
|
||||
HXRefresh("true"), nil
|
||||
}
|
||||
|
||||
// POST /:acctID/inventory/sync-groups/draft/listings
|
||||
func (s *accountSubrouter) createSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
acctID := auth.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
if _, err := s.accts.CreateSyncGroupListingDraft(ctx, acctID); err != nil {
|
||||
return nil, response.Errorf("failed to create new listing draft: %w", err)
|
||||
}
|
||||
|
||||
return response.StatusCreated(), nil
|
||||
}
|
||||
|
||||
// PUT /:acctID/inventory/sync-groups/draft/listings/:orderIndex/shop
|
||||
// @platform string
|
||||
// @shopID string
|
||||
func (s *accountSubrouter) setShopInSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
acctID := auth.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
var (
|
||||
orderIndex int
|
||||
platform accounts.Platform
|
||||
shopID string
|
||||
)
|
||||
|
||||
err := param.Path("orderIndex", param.Int(&orderIndex)).
|
||||
Form("platform", param.Platform(&platform)).
|
||||
Form("shop-id", param.Text(&shopID)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.accts.SetShopInSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil {
|
||||
return nil, response.Errorf("failed to set shop: %w", response.ErrorFromConstant(err))
|
||||
}
|
||||
|
||||
return response.StatusOK(), nil
|
||||
}
|
||||
|
||||
// PUT /:acctID/inventory/sync-groups/draft/listings/:orderIndex/listing
|
||||
func (s *accountSubrouter) setListingInSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
acctID := auth.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
var (
|
||||
orderIndex int
|
||||
listingID string
|
||||
)
|
||||
|
||||
err := param.Path("orderIndex", param.Int(&orderIndex)).
|
||||
Form("listing-id", param.Text(&listingID)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.accts.SetListingInSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil {
|
||||
return nil, response.Errorf("failed to set listing: %w", response.ErrorFromConstant(err))
|
||||
}
|
||||
|
||||
return response.StatusOK(), nil
|
||||
}
|
||||
|
||||
// DELETE /:acctID/inventory/sync-groups/draft/listings/:orderIndex
|
||||
func (s *accountSubrouter) deleteSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
acctID := auth.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
var (
|
||||
orderIndex int
|
||||
)
|
||||
|
||||
err := param.Path("orderIndex", param.Int(&orderIndex)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.accts.DeleteSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil {
|
||||
return nil, response.Errorf("failed to delete listing: %w", response.ErrorFromConstant(err))
|
||||
}
|
||||
|
||||
return response.StatusOK(), nil
|
||||
}
|
||||
|
||||
// POST /:acctID/inventory/sync-groups
|
||||
func (s *accountSubrouter) saveNewSyncGroup(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
acctID := auth.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
if _, err := s.accts.SaveNewSyncGroup(ctx, acctID); err != nil {
|
||||
return nil, response.Errorf("failed to save new sync group: %w", response.ErrorFromConstant(err))
|
||||
}
|
||||
|
||||
return response.StatusCreated(), nil
|
||||
}
|
||||
|
||||
// PUT /:acctID/platforms/:platform/order-index"
|
||||
// this endpoint is called when dragging a platform tab in the accounts page.
|
||||
func (s *accountSubrouter) setOrderOfPlatformOnAccountPage(c *gin.Context) (response.Response, error) {
|
||||
acctID := auth.GetIdentity(c).Account.AccountID
|
||||
|
||||
// validate parameters
|
||||
|
||||
var (
|
||||
platform accounts.Platform
|
||||
orderIndex int
|
||||
)
|
||||
|
||||
err := param.Path("platform", param.Platform(&platform)).
|
||||
Form("order-index", param.Int(&orderIndex)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// update the order
|
||||
|
||||
platforms, prevIndex, err := s.accts.SetOrderOfPlatformOnAccountPage(c, acctID, platform, orderIndex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save record: %w", err)
|
||||
}
|
||||
|
||||
// emit events on all platforms updated, so the tabs can all refresh (including their logic)
|
||||
|
||||
platformEvents := make([]string, max(orderIndex, prevIndex)-min(orderIndex, prevIndex))
|
||||
if orderIndex > prevIndex {
|
||||
for i := range orderIndex - prevIndex {
|
||||
p := platforms[prevIndex+i]
|
||||
platformEvents[i] = fmt.Sprintf("accounts_%d_platforms_%s_order-index", acctID, lowerSnakeCase(p))
|
||||
}
|
||||
} else if orderIndex < prevIndex {
|
||||
for i := range prevIndex - orderIndex {
|
||||
p := platforms[orderIndex+1+i]
|
||||
platformEvents[i] = fmt.Sprintf("accounts_%d_platforms_%s_order-index", acctID, lowerSnakeCase(p))
|
||||
}
|
||||
}
|
||||
if err := s.pub.Push(c, acctID, platformEvents...); err != nil {
|
||||
s.log.Errorf("failed to publish platform order-index events: %v", err)
|
||||
}
|
||||
|
||||
return response.StatusNoContent(), nil
|
||||
}
|
||||
|
||||
// POST /:acctID/platforms/:platform/shops/mocks
|
||||
func (s *accountSubrouter) createMockShop(c *gin.Context) (response.Response, error) {
|
||||
acctID := auth.GetIdentity(c).Account.AccountID
|
||||
|
||||
// validate parameters
|
||||
|
||||
var (
|
||||
platform accounts.Platform
|
||||
name string
|
||||
)
|
||||
|
||||
err := param.Path("platform", param.Platform(&platform)).
|
||||
Form("name", param.Text(&name)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.log.Warnf("not implemented: mock store created: account_id = %d; platform = %s; name = %s", acctID, platform, name)
|
||||
|
||||
// create the mock account
|
||||
|
||||
id, err := s.accts.CreateMockShop(c, acctID, platform, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save record: %w", err)
|
||||
}
|
||||
|
||||
location := fmt.Sprintf("/ui/accounts/%d/platforms/%s/mock-shops/%s", acctID, lowerSnakeCase(platform), id)
|
||||
|
||||
var res response.Response
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
res = response.StatusCreated()
|
||||
} else {
|
||||
res = response.SeeOther(location)
|
||||
}
|
||||
|
||||
return res.HXLocation(location), nil
|
||||
}
|
||||
|
||||
// POST /:acctID/platforms/:platform/shops/mocks/:shop-id/listings
|
||||
func (s *accountSubrouter) addMockListing(c *gin.Context) (response.Response, error) {
|
||||
acctID := auth.GetIdentity(c).Account.AccountID
|
||||
|
||||
var (
|
||||
platform accounts.Platform
|
||||
shopID string
|
||||
name string
|
||||
sku string
|
||||
description string
|
||||
count int
|
||||
)
|
||||
|
||||
err := param.Path("platform", param.Platform(&platform)).
|
||||
Path("shop-id", param.Text(&shopID)).
|
||||
Form("name", param.Text(&name)).
|
||||
Form("sku", param.Text(&sku)).
|
||||
Form("description", param.Text(&description)).
|
||||
Form("count", param.Int(&count)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = s.accts.CreateMockListing(
|
||||
c,
|
||||
accounts.MockListing{
|
||||
AccountShopListingIDs: accounts.NewAccountIDs(acctID).
|
||||
ShopID(platform, shopID).
|
||||
ListingID(""),
|
||||
Name: name,
|
||||
SKU: sku,
|
||||
Description: description,
|
||||
Count: int64(count),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create new listing: %w", err)
|
||||
}
|
||||
|
||||
return response.StatusCreated(), nil
|
||||
}
|
||||
|
||||
// PUT /:acctID/platforms/:platform/shops/mocks/:shop-id/listings/:listing-id
|
||||
func (s *accountSubrouter) updateMockListing(c *gin.Context) (response.Response, error) {
|
||||
acctID := auth.GetIdentity(c).Account.AccountID
|
||||
|
||||
var (
|
||||
platform accounts.Platform
|
||||
shopID string
|
||||
listingID string
|
||||
name string
|
||||
sku string
|
||||
description string
|
||||
count int
|
||||
)
|
||||
|
||||
err := param.Path("platform", param.Platform(&platform)).
|
||||
Path("shop-id", param.Text(&shopID)).
|
||||
Path("listing-id", param.Text(&listingID)).
|
||||
Form("name", param.Text(&name)).
|
||||
Form("sku", param.Text(&sku)).
|
||||
Form("description", param.Text(&description)).
|
||||
Form("count", param.Int(&count)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.accts.UpdateMockListing(
|
||||
c,
|
||||
accounts.MockListing{
|
||||
AccountShopListingIDs: accounts.NewAccountIDs(acctID).
|
||||
ShopID(platform, shopID).
|
||||
ListingID(listingID),
|
||||
Name: name,
|
||||
SKU: sku,
|
||||
Description: description,
|
||||
Count: int64(count),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update listing: %w", err)
|
||||
}
|
||||
|
||||
return response.StatusOK(), nil
|
||||
}
|
||||
|
||||
// DELETE /:acctID/platforms/:platform/shops/mocks/:shop-id/listings/:listing-id
|
||||
func (s *accountSubrouter) deleteMockListing(c *gin.Context) (response.Response, error) {
|
||||
acctID := auth.GetIdentity(c).Account.AccountID
|
||||
|
||||
var (
|
||||
platform accounts.Platform
|
||||
shopID string
|
||||
listingID string
|
||||
)
|
||||
|
||||
err := param.Path("platform", param.Platform(&platform)).
|
||||
Path("shop-id", param.Text(&shopID)).
|
||||
Path("listing-id", param.Text(&listingID)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.accts.DeleteMockListing(
|
||||
c,
|
||||
accounts.NewAccountIDs(acctID).
|
||||
ShopID(platform, shopID).
|
||||
ListingID(listingID),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete listing: %w", err)
|
||||
}
|
||||
|
||||
return response.StatusNoContent(), nil
|
||||
}
|
||||
|
||||
// POST /:acctID/inventory/sync-groups/mock/draft/listings
|
||||
func (s *accountSubrouter) createMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
acctID := auth.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
if _, err := s.accts.CreateMockSyncGroupListingDraft(ctx, acctID); err != nil {
|
||||
return nil, response.Errorf("failed to create new listing draft: %w", err)
|
||||
}
|
||||
|
||||
return response.StatusCreated(), nil
|
||||
}
|
||||
|
||||
// PUT /:acctID/inventory/sync-groups/mock/draft/listings/:orderIndex/shop
|
||||
// @platform string
|
||||
// @shopID string
|
||||
func (s *accountSubrouter) setShopInMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
acctID := auth.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
var (
|
||||
orderIndex int
|
||||
platform accounts.Platform
|
||||
shopID string
|
||||
)
|
||||
|
||||
err := param.Path("orderIndex", param.Int(&orderIndex)).
|
||||
Form("platform", param.Platform(&platform)).
|
||||
Form("shop-id", param.Text(&shopID)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.accts.SetShopInMockSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil {
|
||||
return nil, response.Errorf("failed to set shop: %w", response.ErrorFromConstant(err))
|
||||
}
|
||||
|
||||
return response.StatusOK(), nil
|
||||
}
|
||||
|
||||
// PUT /:acctID/inventory/sync-groups/mock/draft/listings/:orderIndex/listing
|
||||
func (s *accountSubrouter) setListingInMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
acctID := auth.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
var (
|
||||
orderIndex int
|
||||
listingID string
|
||||
)
|
||||
|
||||
err := param.Path("orderIndex", param.Int(&orderIndex)).
|
||||
Form("listing-id", param.Text(&listingID)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.accts.SetListingInMockSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil {
|
||||
return nil, response.Errorf("failed to set listing: %w", response.ErrorFromConstant(err))
|
||||
}
|
||||
|
||||
return response.StatusOK(), nil
|
||||
}
|
||||
|
||||
// DELETE /:acctID/inventory/sync-groups/mock/draft/listings/:orderIndex
|
||||
func (s *accountSubrouter) deleteMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
acctID := auth.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
var (
|
||||
orderIndex int
|
||||
)
|
||||
|
||||
err := param.Path("orderIndex", param.Int(&orderIndex)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.accts.DeleteMockSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil {
|
||||
return nil, response.Errorf("failed to delete listing: %w", response.ErrorFromConstant(err))
|
||||
}
|
||||
|
||||
return response.StatusOK(), nil
|
||||
}
|
||||
|
||||
func lowerSnakeCase(s accounts.Platform) string {
|
||||
return strings.ToLower(strings.Join(strings.Split(string(s), " "), "_"))
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/logging"
|
||||
accounts_api "ruben/inventory2/internal/server/api/accounts"
|
||||
auth_api "ruben/inventory2/internal/server/api/auth"
|
||||
sse_api "ruben/inventory2/internal/server/api/sse"
|
||||
"ruben/inventory2/internal/server/api/webhooks"
|
||||
etsy_webhooks "ruben/inventory2/internal/server/api/webhooks/etsy"
|
||||
"ruben/inventory2/internal/server/auth"
|
||||
"ruben/inventory2/internal/server/sse"
|
||||
)
|
||||
|
||||
func Routes(
|
||||
r *gin.RouterGroup,
|
||||
logger *logging.Logger,
|
||||
auth *auth.Auth,
|
||||
sq *sse.Queue,
|
||||
accts *accounts.Store,
|
||||
unp *sse.UpdateNotificationPublisher,
|
||||
rawEvents *raw_events.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
) {
|
||||
auth_api.Routes(
|
||||
r.Group("/auth"),
|
||||
logger.WithGroup("/auth"),
|
||||
auth.GetAuthenticator(),
|
||||
)
|
||||
sse_api.Routes(
|
||||
r.Group("/events", auth.Authenticate()),
|
||||
logger.WithGroup("/events"),
|
||||
sq,
|
||||
)
|
||||
accounts_api.Routes(
|
||||
r.Group("/accounts", auth.Authenticate()),
|
||||
logger.WithGroup("/accounts"),
|
||||
accts,
|
||||
unp.Group("/accounts"),
|
||||
)
|
||||
webhooks.Routes(
|
||||
r.Group("/webhooks"),
|
||||
logger.WithGroup("/webhooks"),
|
||||
rawEvents,
|
||||
etsy,
|
||||
webhooks.Config{
|
||||
Etsy: etsy_webhooks.Config{
|
||||
OAuthRedirectURIWithAcctIDParam: "/oauth/accounts/:acctID/auth_code",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"ruben/inventory2/internal/domains/authentication"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/cookies"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type loginSubrouter struct {
|
||||
log *logging.Logger
|
||||
auth *authentication.Authenticator
|
||||
}
|
||||
|
||||
func Routes(
|
||||
r *gin.RouterGroup,
|
||||
logger *logging.Logger,
|
||||
auth *authentication.Authenticator,
|
||||
) {
|
||||
ls := &loginSubrouter{
|
||||
log: logger,
|
||||
auth: auth,
|
||||
}
|
||||
|
||||
r.GET("/login", response.Handler(ls.loginPage))
|
||||
r.GET("/login/callback", response.Handler(ls.loginCallback))
|
||||
r.GET("/logout", response.Handler(ls.logoutPage))
|
||||
}
|
||||
|
||||
func (s *loginSubrouter) loginPage(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
|
||||
u, err := NewLoginURL(ctx, s.auth, "/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect(u), nil
|
||||
}
|
||||
|
||||
func NewLoginURL(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) {
|
||||
state, err := auth.NewState(ctx, targetURI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random state: %w", err)
|
||||
}
|
||||
|
||||
base64EncodedState := fmt.Sprintf("%x", state[:])
|
||||
|
||||
return auth.AuthCodeURL(base64EncodedState), nil
|
||||
}
|
||||
|
||||
func (s *loginSubrouter) loginCallback(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
q := r.URL.Query()
|
||||
|
||||
// obtain token and profile
|
||||
|
||||
accessToken, targetURI, expiration, err := s.auth.Exchange(ctx, q.Get("state"), q.Get("code"))
|
||||
if err != nil {
|
||||
return nil, response.Unauthorized().
|
||||
Msg(fmt.Sprintf("Failed to exchange an authorization code for a token")).
|
||||
Wrap(err)
|
||||
}
|
||||
|
||||
// set access_token cookie and redirect to a reasonable place
|
||||
|
||||
return response.TemporaryRedirect(targetURI).
|
||||
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
||||
}
|
||||
|
||||
func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
|
||||
if ck, err := r.Cookie("access_token"); err == nil && ck != nil {
|
||||
if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil {
|
||||
s.log.Error("failed to delete auth token", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/auth"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
"ruben/inventory2/internal/server/sse"
|
||||
)
|
||||
|
||||
const (
|
||||
maxNumOpenConnectionsPerUser = 3
|
||||
)
|
||||
|
||||
type (
|
||||
sseRouter struct {
|
||||
log *logging.Logger
|
||||
sse *sse.Queue
|
||||
|
||||
users map[string][maxNumOpenConnectionsPerUser]context.CancelFunc
|
||||
lock sync.Mutex
|
||||
}
|
||||
)
|
||||
|
||||
func Routes(
|
||||
r gin.IRouter,
|
||||
logger *logging.Logger,
|
||||
sq *sse.Queue,
|
||||
) {
|
||||
s := &sseRouter{
|
||||
log: logger,
|
||||
sse: sq,
|
||||
users: make(map[string][maxNumOpenConnectionsPerUser]context.CancelFunc),
|
||||
}
|
||||
|
||||
r.GET("/", response.Handler(s.serveEvents))
|
||||
}
|
||||
|
||||
func (r *sseRouter) serveEvents(c *gin.Context) (response.Response, error) {
|
||||
acct := auth.GetIdentity(c).Account
|
||||
acctID := acct.AccountID
|
||||
userID := acct.UserID
|
||||
email := acct.Email
|
||||
|
||||
log := r.log.WithGroup("serveEvents").With(
|
||||
"accountID", acctID,
|
||||
"userID", userID,
|
||||
"email", email,
|
||||
"userAgent", c.Request.UserAgent(),
|
||||
)
|
||||
log.Info("user connected to sse queue")
|
||||
|
||||
ctx := r.closeOutstandingConnectionsForUserAndStoreCancelFuncForUser(c, userID)
|
||||
|
||||
w := c.Writer
|
||||
|
||||
hdr := w.Header()
|
||||
hdr.Set("Access-Control-Allow-Origin", "*")
|
||||
hdr.Set("Access-Control-Expose-Headers", "Content-Type")
|
||||
hdr.Set("Content-Type", "text/event-stream")
|
||||
hdr.Set("Connection", "keep-alive")
|
||||
hdr.Set("Cache-Control", "no-cache")
|
||||
w.Flush()
|
||||
|
||||
err := r.sse.Listen(ctx, acctID, func(ctx context.Context, e *sse.Event) error {
|
||||
log.Debugf("sending event of type %s", e.Type)
|
||||
|
||||
e.Write(w)
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("no longer connected to sse queue due to error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// send a 'close' message so the front end doesn't try to reconnect
|
||||
(&sse.Event{Type: "close"}).Write(w)
|
||||
|
||||
log.Info("user disconnecting sse queue")
|
||||
return response.Status(200), nil
|
||||
}
|
||||
|
||||
func (r *sseRouter) closeOutstandingConnectionsForUserAndStoreCancelFuncForUser(ctx context.Context, userID string) context.Context {
|
||||
r.lock.Lock()
|
||||
defer r.lock.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
closeConnFuncs := r.users[userID]
|
||||
defer func() {
|
||||
r.users[userID] = closeConnFuncs
|
||||
}()
|
||||
|
||||
// close existing connection
|
||||
|
||||
for i := range maxNumOpenConnectionsPerUser {
|
||||
if fn := closeConnFuncs[i]; fn == nil {
|
||||
// not hit limit on connections.
|
||||
// save the cancellation func and done
|
||||
closeConnFuncs[i] = cancel
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
|
||||
// close the oldest connection, shift all cancellation funcs down, and push the new one in
|
||||
closeConnFuncs[0]()
|
||||
for i := range maxNumOpenConnectionsPerUser - 1 {
|
||||
closeConnFuncs[i] = closeConnFuncs[i+1]
|
||||
}
|
||||
closeConnFuncs[maxNumOpenConnectionsPerUser-1] = cancel
|
||||
|
||||
return ctx
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package etsy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
OAuthRedirectURIWithAcctIDParam string
|
||||
}
|
||||
)
|
||||
|
||||
func Routes(
|
||||
r *gin.RouterGroup,
|
||||
logger *logging.Logger,
|
||||
db *raw_events.Store,
|
||||
platform *etsy.Platform,
|
||||
cfg Config,
|
||||
) {
|
||||
h := webhooks{
|
||||
log: logger,
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
etsy: platform,
|
||||
}
|
||||
|
||||
r.POST("/test", response.Handler(h.test))
|
||||
|
||||
r.GET(h.cfg.OAuthRedirectURIWithAcctIDParam, response.Handler(h.redirectURI))
|
||||
|
||||
r.GET("/:acctID/new-account-link", response.Handler(h.newAccountLink))
|
||||
}
|
||||
|
||||
type (
|
||||
webhooks struct {
|
||||
log *logging.Logger
|
||||
cfg Config
|
||||
db *raw_events.Store
|
||||
etsy *etsy.Platform
|
||||
}
|
||||
)
|
||||
|
||||
// POST /test
|
||||
func (h webhooks) test(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
|
||||
var body json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return nil, response.Errorf("failed to decode body as json: %w", err)
|
||||
}
|
||||
|
||||
ts := time.Now().UTC()
|
||||
|
||||
storeID := "test-store-id"
|
||||
var payloadObject struct {
|
||||
StoreID string
|
||||
}
|
||||
if err := json.Unmarshal(body, &payloadObject); err == nil && payloadObject.StoreID != "" {
|
||||
storeID = payloadObject.StoreID
|
||||
}
|
||||
|
||||
err := h.db.Save(r.Context(), &raw_events.Event{
|
||||
Platform: "etsy",
|
||||
StoreID: storeID,
|
||||
EventID: fmt.Sprint(ts.Unix()),
|
||||
EventTimestamp: ts,
|
||||
Payload: body,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.Errorf("error occurred saving the body as the event payload: %w", err)
|
||||
}
|
||||
|
||||
return response.Status(201), nil
|
||||
}
|
||||
|
||||
// GET h.cfg.OAuthRedirectURIWithAcctIDParam
|
||||
func (h webhooks) redirectURI(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
|
||||
// get account id for the request
|
||||
|
||||
acctID, err := strconv.ParseInt(c.Param("acctID"), 10, 64)
|
||||
if err != nil || acctID <= 0 {
|
||||
return nil, response.NotFound()
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
q := r.URL.Query()
|
||||
state := q.Get("state")
|
||||
|
||||
// handle failed, potentially non-consenting, request
|
||||
|
||||
if errCode := q.Get("error"); errCode != "" {
|
||||
errDesc := q.Get("error_description")
|
||||
errURI := q.Get("error_uri")
|
||||
|
||||
fmt.Printf(
|
||||
"error in obtaining an OAuth Token: error=%s, error_desc=%s, error_uri=%s, account_id=%d\n",
|
||||
errCode,
|
||||
errDesc,
|
||||
errURI,
|
||||
acctID,
|
||||
)
|
||||
|
||||
h.etsy.InvalidateState(ctx, state)
|
||||
|
||||
return response.Status(200), nil
|
||||
}
|
||||
|
||||
// validate the state to prevent CSRF attacks
|
||||
|
||||
ok, err := h.etsy.HandleNewAuthCode(ctx, acctID, state, q.Get("code"))
|
||||
if err != nil {
|
||||
h.log.Error("failed to handle new auth code", "error", err)
|
||||
return nil, response.Forbidden()
|
||||
}
|
||||
if !ok {
|
||||
return nil, response.Forbidden()
|
||||
}
|
||||
|
||||
// redirect to the user's account page
|
||||
|
||||
return response.SeeOther(fmt.Sprintf("/accounts/%d", acctID)), nil
|
||||
}
|
||||
|
||||
// GET /:acctID/new-account-link
|
||||
func (h webhooks) newAccountLink(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
acctIDStr := c.Param("acctID")
|
||||
acctID, err := strconv.ParseInt(acctIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, response.NotFound().Msgf("account %s not found", acctIDStr)
|
||||
}
|
||||
|
||||
u, err := h.etsy.GenerateConnectionURLForNewAccount(r.Context(), acctID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate url for account %d: %w", acctID, err)
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect(u.String()), nil
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package tiktok
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Routes(
|
||||
r *gin.RouterGroup,
|
||||
logger *logging.Logger,
|
||||
db *raw_events.Store,
|
||||
) {
|
||||
r.POST("/test", response.Handler(func(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
|
||||
var body json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode body as json: %w", err)
|
||||
}
|
||||
|
||||
ts := time.Now().UTC()
|
||||
|
||||
err := db.Save(r.Context(), &raw_events.Event{
|
||||
Platform: "tiktok",
|
||||
StoreID: "test-store-1",
|
||||
EventID: fmt.Sprint(ts.Unix()),
|
||||
EventTimestamp: ts,
|
||||
Payload: body,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error occurred saving the body as the event payload: %w", err)
|
||||
}
|
||||
|
||||
return response.Status(201), nil
|
||||
}))
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/api/webhooks/etsy"
|
||||
"ruben/inventory2/internal/server/api/webhooks/tiktok"
|
||||
"ruben/inventory2/internal/server/api/webhooks/wix"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Etsy etsy.Config
|
||||
}
|
||||
|
||||
func Routes(
|
||||
r *gin.RouterGroup,
|
||||
logger *logging.Logger,
|
||||
eventsDB *raw_events.Store,
|
||||
etsyPlatform *etsy_platform.Platform,
|
||||
cfg Config,
|
||||
) {
|
||||
etsy.Routes(
|
||||
r.Group("/etsy"),
|
||||
logger.WithGroup("etsy"),
|
||||
eventsDB,
|
||||
etsyPlatform,
|
||||
cfg.Etsy,
|
||||
)
|
||||
tiktok.Routes(
|
||||
r.Group("/tiktok"),
|
||||
logger.WithGroup("tiktok"),
|
||||
eventsDB,
|
||||
)
|
||||
wix.Routes(
|
||||
r.Group("/wix"),
|
||||
logger.WithGroup("wix"),
|
||||
eventsDB,
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package wix
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Routes(
|
||||
r *gin.RouterGroup,
|
||||
logger *logging.Logger,
|
||||
db *raw_events.Store,
|
||||
) {
|
||||
r.POST("/test", response.Handler(func(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
|
||||
var body json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode body as json: %w", err)
|
||||
}
|
||||
|
||||
ts := time.Now().UTC()
|
||||
|
||||
err := db.Save(r.Context(), &raw_events.Event{
|
||||
Platform: "wix",
|
||||
StoreID: "test-store-1",
|
||||
EventID: fmt.Sprint(ts.Unix()),
|
||||
EventTimestamp: ts,
|
||||
Payload: body,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error occurred saving the body as the event payload: %w", err)
|
||||
}
|
||||
|
||||
return response.Status(201), nil
|
||||
}))
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"ruben/inventory2/internal/consts"
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"ruben/inventory2/internal/domains/authentication"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/cookies"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type (
|
||||
Auth struct {
|
||||
log *logging.Logger
|
||||
auth *authentication.Authenticator
|
||||
accts *accounts.Store
|
||||
}
|
||||
|
||||
Identity struct {
|
||||
AccessToken string
|
||||
Claims authentication.AccessTokenClaims
|
||||
User accounts.OAuthUser
|
||||
Account *accounts.Account
|
||||
}
|
||||
|
||||
LoginURLProviderFunc = func(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error)
|
||||
|
||||
AuthorizationAssertions = response.HandlerFunc
|
||||
)
|
||||
|
||||
func NewAuth(
|
||||
logger *logging.Logger,
|
||||
auth *authentication.Authenticator,
|
||||
accts *accounts.Store,
|
||||
) *Auth {
|
||||
return &Auth{
|
||||
log: logger,
|
||||
auth: auth,
|
||||
accts: accts,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) GetAuthenticator() *authentication.Authenticator {
|
||||
return a.auth
|
||||
}
|
||||
|
||||
// Identify will add an Identity to the context that can then be retrieved via GetIdentity.
|
||||
func (a *Auth) Identify(c *gin.Context) {
|
||||
if err := a.addIdentity(c); err != nil {
|
||||
c.Error(err)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) addIdentity(c *gin.Context) error {
|
||||
r := c.Request
|
||||
|
||||
ck, err := r.Cookie("access_token")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
accessToken := ck.Value
|
||||
|
||||
claims, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, consts.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return response.Errorf("failed to load authentication details: %w", err)
|
||||
}
|
||||
|
||||
expiration := claims.Expiration
|
||||
if expiration.Before(time.Now()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
return response.Errorf("failed to load user and account defails: %w", err)
|
||||
}
|
||||
|
||||
c.Request = r.WithContext(SetIdentity(c, Identity{
|
||||
AccessToken: accessToken,
|
||||
Claims: claims,
|
||||
User: user,
|
||||
Account: acct,
|
||||
}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate should only be used along with and after Identify
|
||||
// Typically used with response.Handler to make a gin.HandlerFunc.
|
||||
func (a *Auth) Authenticate(assertions ...AuthorizationAssertions) func(c *gin.Context) {
|
||||
return response.Handler(a.AuthenticateHandler(assertions...))
|
||||
}
|
||||
|
||||
// See Authenticate.
|
||||
func (a *Auth) AuthenticateHandler(assertions ...AuthorizationAssertions) func(c *gin.Context) (response.Response, error) {
|
||||
return func(c *gin.Context) (response.Response, error) {
|
||||
id, ok := getIdentity(c)
|
||||
if !ok {
|
||||
return nil, response.Unauthorized().
|
||||
HTML([]byte(`
|
||||
<h1>Unauthorized</h1>
|
||||
<a href="/">Return to app</a>
|
||||
`)) // TODO: would be nice to have a better page for this
|
||||
}
|
||||
|
||||
expiration := id.Claims.Expiration
|
||||
now := time.Now()
|
||||
|
||||
// refresh tokens, when the access token is "old enough"
|
||||
|
||||
// id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in.
|
||||
const idTokenLifetime = 48 * time.Hour
|
||||
if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) {
|
||||
accessToken, expiration, err := a.auth.RefreshAccessToken(c, id.AccessToken)
|
||||
if err != nil {
|
||||
a.log.Warn("failed to refresh access token", "error", err)
|
||||
return response.TemporaryRedirect("/").
|
||||
Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))).
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
|
||||
// 'redirect' to same url, to set the new access_token cookie
|
||||
return response.TemporaryRedirect(c.Request.URL.String()).
|
||||
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
||||
}
|
||||
|
||||
for _, as := range assertions {
|
||||
if res, err := as(c); res != nil || err != nil {
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
type identityKey struct{}
|
||||
|
||||
// stores identity in context
|
||||
func SetIdentity(ctx context.Context, id Identity) context.Context {
|
||||
c, ok := ctx.(*gin.Context)
|
||||
if ok {
|
||||
c.Set(identityKey{}, id)
|
||||
ctx = c.Request.Context()
|
||||
}
|
||||
return context.WithValue(ctx, identityKey{}, id)
|
||||
}
|
||||
|
||||
// get identity from context
|
||||
func GetIdentity(ctx context.Context) Identity {
|
||||
id, _ := getIdentity(ctx)
|
||||
return id
|
||||
}
|
||||
|
||||
func getIdentity(ctx context.Context) (Identity, bool) {
|
||||
id, ok := ctx.Value(identityKey{}).(Identity)
|
||||
if ok {
|
||||
return id, true
|
||||
}
|
||||
|
||||
c, ok := ctx.(*gin.Context)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
v, ok := c.Get(identityKey{})
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
id, ok = v.(Identity)
|
||||
|
||||
return id, ok
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package cookies
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AccessToken(tkn string, expiration time.Time) http.Cookie {
|
||||
return http.Cookie{
|
||||
Name: "access_token",
|
||||
Value: tkn,
|
||||
Path: "/",
|
||||
Expires: expiration,
|
||||
MaxAge: 0, // using Expiration instead
|
||||
Secure: true,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package cookies
|
||||
|
||||
import "net/http"
|
||||
|
||||
func Expired(name string) http.Cookie {
|
||||
return http.Cookie{
|
||||
Name: name,
|
||||
Path: "/",
|
||||
MaxAge: -1, // expire the cookie
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package param
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// encoding.TextUnmarshaler instances for Spec
|
||||
|
||||
func Text(dst *string) encoding.TextUnmarshaler {
|
||||
return (*rawText)(dst)
|
||||
}
|
||||
|
||||
func Int(dst *int) encoding.TextUnmarshaler {
|
||||
return (*intText)(dst)
|
||||
}
|
||||
|
||||
func Platform(dst *accounts.Platform) encoding.TextUnmarshaler {
|
||||
return (*platformText)(dst)
|
||||
}
|
||||
|
||||
type (
|
||||
rawText string
|
||||
intText int
|
||||
platformText accounts.Platform
|
||||
)
|
||||
|
||||
func (t *rawText) UnmarshalText(text []byte) error {
|
||||
*t = rawText(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *intText) UnmarshalText(text []byte) error {
|
||||
i, err := strconv.Atoi(string(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*n = intText(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *platformText) UnmarshalText(text []byte) error {
|
||||
v, err := accounts.NewPlatform(string(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*p = platformText(v)
|
||||
return nil
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package param
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ruben/inventory2/internal/server/response"
|
||||
)
|
||||
|
||||
// Spec is the entry point of the package.
|
||||
// It's typically constructed via Path() or Form().
|
||||
// But it's zero value is valid.
|
||||
//
|
||||
// It's methods support a builder pattern to minimize API bloat.
|
||||
//
|
||||
// To complete gin parameter parsing, call Unmarshal().
|
||||
type Spec struct {
|
||||
path map[string]encoding.TextUnmarshaler
|
||||
form map[string]encoding.TextUnmarshaler
|
||||
}
|
||||
|
||||
func Path(k string, dst encoding.TextUnmarshaler) Spec {
|
||||
return Spec{
|
||||
path: map[string]encoding.TextUnmarshaler{
|
||||
k: dst,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Form(k string, dst encoding.TextUnmarshaler) Spec {
|
||||
return Spec{
|
||||
form: map[string]encoding.TextUnmarshaler{
|
||||
k: dst,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s Spec) Path(k string, dst encoding.TextUnmarshaler) Spec {
|
||||
if s.path == nil {
|
||||
s.path = make(map[string]encoding.TextUnmarshaler, 1)
|
||||
}
|
||||
s.path[k] = dst
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Spec) Form(k string, dst encoding.TextUnmarshaler) Spec {
|
||||
if s.form == nil {
|
||||
s.form = make(map[string]encoding.TextUnmarshaler, 1)
|
||||
}
|
||||
s.form[k] = dst
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Spec) Unmarshal(c *gin.Context) error {
|
||||
for k, dst := range s.path {
|
||||
v := c.Param(k)
|
||||
if v == "" {
|
||||
return response.NotFound().Msgf("no %s provided", k)
|
||||
}
|
||||
if err := dst.UnmarshalText([]byte(v)); err != nil {
|
||||
return response.NotFound().Wrap(err).Msgf("invalid %s", k)
|
||||
}
|
||||
}
|
||||
for k, dst := range s.form {
|
||||
v, ok := c.GetPostForm(k)
|
||||
if !ok || v == "" {
|
||||
return response.BadRequest().Msgf("no %s provided", k)
|
||||
}
|
||||
if err := dst.UnmarshalText([]byte(v)); err != nil {
|
||||
return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package redirect
|
||||
|
||||
import "net/http"
|
||||
|
||||
type (
|
||||
Code int
|
||||
)
|
||||
|
||||
var (
|
||||
MovedPermanently = Code(http.StatusMovedPermanently)
|
||||
Found = Code(http.StatusFound)
|
||||
SeeOther = Code(http.StatusSeeOther)
|
||||
TemporaryRedirect = Code(http.StatusTemporaryRedirect)
|
||||
PermanentRedirect = Code(http.StatusPermanentRedirect)
|
||||
)
|
||||
|
||||
func (c Code) Int() int {
|
||||
return int(c)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
bodyRes struct {
|
||||
body io.ReadCloser
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = bodyRes{}
|
||||
|
||||
func Body(body io.ReadCloser) Response {
|
||||
return bodyRes{
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (b bodyRes) String() string {
|
||||
if b.res != nil {
|
||||
return fmt.Sprintf(`{"body": %q, "nested": %s}`, b.body, b.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"body": %q}`, b.body)
|
||||
}
|
||||
|
||||
func (b bodyRes) wrap(res Response) Response {
|
||||
b.res = res
|
||||
return b
|
||||
}
|
||||
|
||||
func (b bodyRes) Status(code int) Response {
|
||||
return Status(code).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) Body(body io.ReadCloser) Response {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
func (b bodyRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXPushURL(v string) Response {
|
||||
return HXPushURL(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXLocation(v string) Response {
|
||||
return HXLocation(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXPushUrl(v string) Response {
|
||||
return HXPushUrl(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXRedirect(v string) Response {
|
||||
return HXRedirect(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXRefresh(v string) Response {
|
||||
return HXRefresh(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXReplaceUrl(v string) Response {
|
||||
return HXReplaceUrl(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXReswap(v string) Response {
|
||||
return HXReswap(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXRetarget(v string) Response {
|
||||
return HXRetarget(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXReselect(v string) Response {
|
||||
return HXReselect(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXTrigger(v string) Response {
|
||||
return HXTrigger(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXTriggerAfterSettle(v string) Response {
|
||||
return HXTriggerAfterSettle(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HXTriggerAfterSwap(v string) Response {
|
||||
return HXTriggerAfterSwap(v).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) GetStatus() (int, bool) {
|
||||
if b.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return b.res.GetStatus()
|
||||
}
|
||||
|
||||
func (b bodyRes) getHeaders() [][2]string {
|
||||
if b.res != nil {
|
||||
return b.res.getHeaders()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b bodyRes) GetRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if b.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return b.res.GetRedirect()
|
||||
}
|
||||
|
||||
func (b bodyRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
return b.body, true, nil
|
||||
}
|
||||
|
||||
func (b bodyRes) getCookies() []http.Cookie {
|
||||
if b.res != nil {
|
||||
return b.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b bodyRes) getContentType() (contentType string, ok bool) {
|
||||
if b.res != nil {
|
||||
return b.res.getContentType()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
cookieRes struct {
|
||||
cookie http.Cookie
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = cookieRes{}
|
||||
|
||||
func Cookie(c http.Cookie) Response {
|
||||
return cookieRes{
|
||||
cookie: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (c cookieRes) String() string {
|
||||
if c.res != nil {
|
||||
return fmt.Sprintf(`{"cookie": %q, "nested": %s}`, &c.cookie, c.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"cookie": %q}`, &c.cookie)
|
||||
}
|
||||
|
||||
func (c cookieRes) wrap(res Response) Response {
|
||||
c.res = res
|
||||
return c
|
||||
}
|
||||
|
||||
func (c cookieRes) Status(code int) Response {
|
||||
return Status(code).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXPushURL(v string) Response {
|
||||
return HXPushURL(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXLocation(v string) Response {
|
||||
return HXLocation(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXPushUrl(v string) Response {
|
||||
return HXPushUrl(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXRedirect(v string) Response {
|
||||
return HXRedirect(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXRefresh(v string) Response {
|
||||
return HXRefresh(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXReplaceUrl(v string) Response {
|
||||
return HXReplaceUrl(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXReswap(v string) Response {
|
||||
return HXReswap(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXRetarget(v string) Response {
|
||||
return HXRetarget(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXReselect(v string) Response {
|
||||
return HXReselect(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXTrigger(v string) Response {
|
||||
return HXTrigger(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXTriggerAfterSettle(v string) Response {
|
||||
return HXTriggerAfterSettle(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HXTriggerAfterSwap(v string) Response {
|
||||
return HXTriggerAfterSwap(v).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) GetStatus() (int, bool) {
|
||||
if c.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return c.res.GetStatus()
|
||||
}
|
||||
|
||||
func (c cookieRes) getHeaders() [][2]string {
|
||||
if c.res != nil {
|
||||
return c.res.getHeaders()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c cookieRes) GetRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if c.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return c.res.GetRedirect()
|
||||
}
|
||||
|
||||
func (c cookieRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
if c.res == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return c.res.getBody()
|
||||
}
|
||||
|
||||
func (c cookieRes) getCookies() []http.Cookie {
|
||||
if c.res != nil {
|
||||
return append(c.res.getCookies(), c.cookie)
|
||||
}
|
||||
|
||||
return []http.Cookie{c.cookie}
|
||||
}
|
||||
|
||||
func (c cookieRes) getContentType() (contentType string, ok bool) {
|
||||
if c.res != nil {
|
||||
return c.res.getContentType()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/consts"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
// ErrorResponse is an error
|
||||
ErrorResponse struct {
|
||||
err error
|
||||
msg string
|
||||
status int
|
||||
html []byte
|
||||
}
|
||||
)
|
||||
|
||||
// Constructors
|
||||
|
||||
func Errorf(format string, args ...any) ErrorResponse {
|
||||
return ErrorResponse{
|
||||
err: fmt.Errorf(format, args...),
|
||||
}
|
||||
}
|
||||
|
||||
func BadRequest() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func NotFound() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
func Unauthorized() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
func Forbidden() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
|
||||
func Conflict() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusConflict,
|
||||
}
|
||||
}
|
||||
|
||||
// builder pattern implementation
|
||||
|
||||
func (e ErrorResponse) Msg(msg string) ErrorResponse {
|
||||
e.msg = msg
|
||||
return e
|
||||
}
|
||||
|
||||
func (e ErrorResponse) Msgf(format string, args ...any) ErrorResponse {
|
||||
e.msg = fmt.Sprintf(format, args...)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e ErrorResponse) Status(status int) ErrorResponse {
|
||||
e.status = status
|
||||
return e
|
||||
}
|
||||
|
||||
func (e ErrorResponse) Wrap(err error) ErrorResponse {
|
||||
e.err = err
|
||||
return e
|
||||
}
|
||||
|
||||
func (e ErrorResponse) HTML(h []byte) ErrorResponse {
|
||||
e.html = h
|
||||
return e
|
||||
}
|
||||
|
||||
// error implementation
|
||||
|
||||
func (e ErrorResponse) Error() string {
|
||||
parts := make([]string, 0, 3)
|
||||
|
||||
if e.msg != "" {
|
||||
parts = append(parts, e.msg)
|
||||
} else if e.status != 0 {
|
||||
parts = append(parts, fmt.Sprintf("status = %d", e.status))
|
||||
}
|
||||
if e.err != nil {
|
||||
parts = append(parts, e.err.Error())
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "status = 500"
|
||||
}
|
||||
|
||||
return strings.Join(parts, ": ")
|
||||
}
|
||||
|
||||
func (e ErrorResponse) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// nested response value resolution
|
||||
|
||||
func (e ErrorResponse) GetStatus() (int, bool) {
|
||||
if e.status != 0 {
|
||||
return e.status, true
|
||||
}
|
||||
|
||||
ce, ok := GetError(e.err)
|
||||
if ok {
|
||||
return ce.GetStatus()
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (e ErrorResponse) GetMsg() (string, bool) {
|
||||
if e.msg != "" {
|
||||
return e.msg, true
|
||||
}
|
||||
|
||||
ce, ok := GetError(e.err)
|
||||
if ok {
|
||||
return ce.GetMsg()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (e ErrorResponse) GetHTML() ([]byte, bool) {
|
||||
if len(e.html) != 0 {
|
||||
return e.html, true
|
||||
}
|
||||
|
||||
ce, ok := GetError(e.err)
|
||||
if ok {
|
||||
return ce.GetHTML()
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func GetError(err error) (e ErrorResponse, ok bool) {
|
||||
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
|
||||
}
|
||||
|
||||
// error wrapping utilities
|
||||
|
||||
func ErrorFromConstant(err error) error {
|
||||
cerr := err
|
||||
for cerr != nil {
|
||||
switch cerr {
|
||||
case consts.ErrNotFound:
|
||||
return NotFound()
|
||||
case consts.ErrConflict:
|
||||
return Conflict()
|
||||
}
|
||||
|
||||
uerr, ok := cerr.(interface {
|
||||
Unwrap() error
|
||||
})
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
cerr = uerr.Unwrap()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type (
|
||||
HandlerFunc = func(c *gin.Context) (Response, error)
|
||||
|
||||
Middleware = func(HandlerFunc) HandlerFunc
|
||||
|
||||
responseKey struct{}
|
||||
)
|
||||
|
||||
func Handler(f HandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
res, err := f(c)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
c.Abort()
|
||||
} else if res != nil {
|
||||
c.Set(responseKey{}, res)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
headerRes struct {
|
||||
name string
|
||||
value string
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = headerRes{}
|
||||
|
||||
func header(name, value string) Response {
|
||||
return headerRes{
|
||||
name: name,
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
|
||||
func HXPushURL(v string) Response {
|
||||
return header("HX-Push-Url", v)
|
||||
}
|
||||
|
||||
func HXLocation(v string) Response {
|
||||
return header("HX-Location", v)
|
||||
}
|
||||
|
||||
func HXPushUrl(v string) Response {
|
||||
return header("HX-Push-Url", v)
|
||||
}
|
||||
|
||||
func HXRedirect(v string) Response {
|
||||
return header("HX-Redirect", v)
|
||||
}
|
||||
|
||||
func HXRefresh(v string) Response {
|
||||
return header("HX-Refresh", v)
|
||||
}
|
||||
|
||||
func HXReplaceUrl(v string) Response {
|
||||
return header("HX-Replace-Url", v)
|
||||
}
|
||||
|
||||
func HXReswap(v string) Response {
|
||||
return header("HX-Reswap", v)
|
||||
}
|
||||
|
||||
func HXRetarget(v string) Response {
|
||||
return header("HX-Retarget", v)
|
||||
}
|
||||
|
||||
func HXReselect(v string) Response {
|
||||
return header("HX-Reselect", v)
|
||||
}
|
||||
|
||||
func HXTrigger(v string) Response {
|
||||
return header("HX-Trigger", v)
|
||||
}
|
||||
|
||||
func HXTriggerAfterSettle(v string) Response {
|
||||
return header("HX-Trigger-After-Settle", v)
|
||||
}
|
||||
|
||||
func HXTriggerAfterSwap(v string) Response {
|
||||
return header("HX-Trigger-After-Swap", v)
|
||||
}
|
||||
|
||||
func (h headerRes) String() string {
|
||||
if h.res != nil {
|
||||
return fmt.Sprintf(`{"header": {"name": %q, "value": %q}, "nested": %s}`, h.name, h.value, h.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"header": {"name": %q, "value": %q}}`, h.name, h.value)
|
||||
}
|
||||
|
||||
func (h headerRes) wrap(res Response) Response {
|
||||
h.res = res
|
||||
return h
|
||||
}
|
||||
|
||||
func (h headerRes) Status(code int) Response {
|
||||
return Status(code).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) Header(name, value string) Response {
|
||||
h.name = name
|
||||
h.value = value
|
||||
return h
|
||||
}
|
||||
|
||||
func (h headerRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXPushURL(v string) Response {
|
||||
return HXPushURL(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXLocation(v string) Response {
|
||||
return HXLocation(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXPushUrl(v string) Response {
|
||||
return HXPushUrl(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXRedirect(v string) Response {
|
||||
return HXRedirect(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXRefresh(v string) Response {
|
||||
return HXRefresh(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXReplaceUrl(v string) Response {
|
||||
return HXReplaceUrl(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXReswap(v string) Response {
|
||||
return HXReswap(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXRetarget(v string) Response {
|
||||
return HXRetarget(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXReselect(v string) Response {
|
||||
return HXReselect(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXTrigger(v string) Response {
|
||||
return HXTrigger(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXTriggerAfterSettle(v string) Response {
|
||||
return HXTriggerAfterSettle(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HXTriggerAfterSwap(v string) Response {
|
||||
return HXTriggerAfterSwap(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) GetStatus() (int, bool) {
|
||||
if h.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return h.res.GetStatus()
|
||||
}
|
||||
|
||||
func (h headerRes) getHeaders() [][2]string {
|
||||
hdr := [2]string{h.name, h.value}
|
||||
if h.res == nil {
|
||||
return [][2]string{hdr}
|
||||
}
|
||||
|
||||
return append(h.res.getHeaders(), hdr)
|
||||
}
|
||||
|
||||
func (h headerRes) GetRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if h.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return h.res.GetRedirect()
|
||||
}
|
||||
|
||||
func (h headerRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
if h.res == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return h.res.getBody()
|
||||
}
|
||||
|
||||
func (h headerRes) getCookies() []http.Cookie {
|
||||
if h.res != nil {
|
||||
return h.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h headerRes) getContentType() (contentType string, ok bool) {
|
||||
if h.res != nil {
|
||||
return h.res.getContentType()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
htmlRes struct {
|
||||
body []byte
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = htmlRes{}
|
||||
|
||||
func HTML(body []byte) Response {
|
||||
return htmlRes{
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (h htmlRes) String() string {
|
||||
if h.res != nil {
|
||||
return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"body": %q}`, h.body)
|
||||
}
|
||||
|
||||
func (h htmlRes) wrap(res Response) Response {
|
||||
h.res = res
|
||||
return h
|
||||
}
|
||||
|
||||
func (h htmlRes) Status(code int) Response {
|
||||
return Status(code).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HTML(body []byte) Response {
|
||||
h.body = body
|
||||
return h
|
||||
}
|
||||
|
||||
func (h htmlRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXPushURL(v string) Response {
|
||||
return HXPushURL(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXLocation(v string) Response {
|
||||
return HXLocation(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXPushUrl(v string) Response {
|
||||
return HXPushUrl(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXRedirect(v string) Response {
|
||||
return HXRedirect(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXRefresh(v string) Response {
|
||||
return HXRefresh(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXReplaceUrl(v string) Response {
|
||||
return HXReplaceUrl(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXReswap(v string) Response {
|
||||
return HXReswap(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXRetarget(v string) Response {
|
||||
return HXRetarget(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXReselect(v string) Response {
|
||||
return HXReselect(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXTrigger(v string) Response {
|
||||
return HXTrigger(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXTriggerAfterSettle(v string) Response {
|
||||
return HXTriggerAfterSettle(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HXTriggerAfterSwap(v string) Response {
|
||||
return HXTriggerAfterSwap(v).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) GetStatus() (int, bool) {
|
||||
if h.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return h.res.GetStatus()
|
||||
}
|
||||
|
||||
func (h htmlRes) getHeaders() [][2]string {
|
||||
if h.res != nil {
|
||||
return h.res.getHeaders()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h htmlRes) GetRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if h.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return h.res.GetRedirect()
|
||||
}
|
||||
|
||||
func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
return io.NopCloser(bytes.NewBuffer(h.body)), true, nil
|
||||
}
|
||||
|
||||
func (h htmlRes) getCookies() []http.Cookie {
|
||||
if h.res != nil {
|
||||
return h.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h htmlRes) getContentType() (contentType string, ok bool) {
|
||||
return "text/html", true
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
jsonRes struct {
|
||||
body any
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = jsonRes{}
|
||||
|
||||
func JSON(body any) Response {
|
||||
return jsonRes{
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (j jsonRes) String() string {
|
||||
if j.res != nil {
|
||||
return fmt.Sprintf(`{"body": %q, "nested": %s}`, j.body, j.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"body": %q}`, j.body)
|
||||
}
|
||||
|
||||
func (j jsonRes) wrap(res Response) Response {
|
||||
j.res = res
|
||||
return j
|
||||
}
|
||||
|
||||
func (j jsonRes) Status(code int) Response {
|
||||
return Status(code).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) JSON(body any) Response {
|
||||
j.body = body
|
||||
return j
|
||||
}
|
||||
|
||||
func (j jsonRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXPushURL(v string) Response {
|
||||
return HXPushURL(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXLocation(v string) Response {
|
||||
return HXLocation(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXPushUrl(v string) Response {
|
||||
return HXPushUrl(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXRedirect(v string) Response {
|
||||
return HXRedirect(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXRefresh(v string) Response {
|
||||
return HXRefresh(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXReplaceUrl(v string) Response {
|
||||
return HXReplaceUrl(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXReswap(v string) Response {
|
||||
return HXReswap(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXRetarget(v string) Response {
|
||||
return HXRetarget(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXReselect(v string) Response {
|
||||
return HXReselect(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXTrigger(v string) Response {
|
||||
return HXTrigger(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXTriggerAfterSettle(v string) Response {
|
||||
return HXTriggerAfterSettle(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HXTriggerAfterSwap(v string) Response {
|
||||
return HXTriggerAfterSwap(v).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) GetStatus() (int, bool) {
|
||||
if j.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return j.res.GetStatus()
|
||||
}
|
||||
|
||||
func (j jsonRes) getHeaders() [][2]string {
|
||||
if j.res != nil {
|
||||
return j.res.getHeaders()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j jsonRes) GetRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if j.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return j.res.GetRedirect()
|
||||
}
|
||||
|
||||
func (j jsonRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
buf := new(bytes.Buffer)
|
||||
return io.NopCloser(buf), true, json.NewEncoder(buf).Encode(j.body)
|
||||
}
|
||||
|
||||
func (j jsonRes) getCookies() []http.Cookie {
|
||||
if j.res != nil {
|
||||
return j.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j jsonRes) getContentType() (contentType string, ok bool) {
|
||||
return "application/json", true
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
redirectRes struct {
|
||||
code redirect.Code
|
||||
to string
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = redirectRes{}
|
||||
|
||||
// convenience constructors
|
||||
|
||||
func MovedPermanently(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.MovedPermanently,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func Found(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.Found,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func SeeOther(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.SeeOther,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func TemporaryRedirect(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.TemporaryRedirect,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func PermanentRedirect(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.PermanentRedirect,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func Redirect(code redirect.Code, to string) Response {
|
||||
return redirectRes{
|
||||
code: code,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func (r redirectRes) String() string {
|
||||
if r.res != nil {
|
||||
return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}, "nested": %s}`, r.code, r.to, r.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}}`, r.code, r.to)
|
||||
}
|
||||
|
||||
func (r redirectRes) wrap(res Response) Response {
|
||||
r.res = res
|
||||
return r
|
||||
}
|
||||
|
||||
func (r redirectRes) Status(code int) Response {
|
||||
return Status(code).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) Redirect(code redirect.Code, to string) Response {
|
||||
r.code = code
|
||||
r.to = to
|
||||
return r
|
||||
}
|
||||
|
||||
func (r redirectRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXPushURL(v string) Response {
|
||||
return HXPushURL(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXLocation(v string) Response {
|
||||
return HXLocation(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXPushUrl(v string) Response {
|
||||
return HXPushUrl(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXRedirect(v string) Response {
|
||||
return HXRedirect(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXRefresh(v string) Response {
|
||||
return HXRefresh(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXReplaceUrl(v string) Response {
|
||||
return HXReplaceUrl(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXReswap(v string) Response {
|
||||
return HXReswap(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXRetarget(v string) Response {
|
||||
return HXRetarget(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXReselect(v string) Response {
|
||||
return HXReselect(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXTrigger(v string) Response {
|
||||
return HXTrigger(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXTriggerAfterSettle(v string) Response {
|
||||
return HXTriggerAfterSettle(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HXTriggerAfterSwap(v string) Response {
|
||||
return HXTriggerAfterSwap(v).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) GetStatus() (int, bool) {
|
||||
if r.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return r.res.GetStatus()
|
||||
}
|
||||
|
||||
func (r redirectRes) getHeaders() [][2]string {
|
||||
if r.res != nil {
|
||||
return r.res.getHeaders()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r redirectRes) GetRedirect() (code redirect.Code, to string, ok bool) {
|
||||
return r.code, r.to, true
|
||||
}
|
||||
|
||||
func (r redirectRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
if r.res == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return r.res.getBody()
|
||||
}
|
||||
|
||||
func (r redirectRes) getCookies() []http.Cookie {
|
||||
if r.res != nil {
|
||||
return r.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r redirectRes) getContentType() (contentType string, ok bool) {
|
||||
if r.res != nil {
|
||||
return r.res.getContentType()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
Response interface {
|
||||
Status(int) Response
|
||||
HXPushURL(string) Response
|
||||
HXLocation(string) Response
|
||||
HXPushUrl(string) Response
|
||||
HXRedirect(string) Response
|
||||
HXRefresh(string) Response
|
||||
HXReplaceUrl(string) Response
|
||||
HXReswap(string) Response
|
||||
HXRetarget(string) Response
|
||||
HXReselect(string) Response
|
||||
HXTrigger(string) Response
|
||||
HXTriggerAfterSettle(string) Response
|
||||
HXTriggerAfterSwap(string) Response
|
||||
Redirect(code redirect.Code, to string) Response
|
||||
Body(io.ReadCloser) Response
|
||||
HTML([]byte) Response
|
||||
JSON(any) Response
|
||||
Cookie(http.Cookie) Response
|
||||
|
||||
GetStatus() (code int, ok bool)
|
||||
getBody() (body io.ReadCloser, ok bool, err error)
|
||||
GetRedirect() (code redirect.Code, to string, ok bool)
|
||||
getCookies() []http.Cookie
|
||||
getContentType() (contentType string, ok bool)
|
||||
getHeaders() [][2]string
|
||||
|
||||
wrap(Response) Response
|
||||
}
|
||||
)
|
||||
@@ -1,168 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
statusRes struct {
|
||||
code int
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = statusRes{}
|
||||
|
||||
func Status(code int) Response {
|
||||
return statusRes{
|
||||
code: code,
|
||||
}
|
||||
}
|
||||
|
||||
func StatusOK() Response {
|
||||
return Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func StatusCreated() Response {
|
||||
return Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func StatusAccepted() Response {
|
||||
return Status(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func StatusNoContent() Response {
|
||||
return Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s statusRes) String() string {
|
||||
if s.res != nil {
|
||||
return fmt.Sprintf(`{"status": %d, "nested": %s}`, s.code, s.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"status": %d}`, s.code)
|
||||
}
|
||||
|
||||
func (s statusRes) wrap(res Response) Response {
|
||||
s.res = res
|
||||
return s
|
||||
}
|
||||
|
||||
func (s statusRes) Status(code int) Response {
|
||||
s.code = code
|
||||
return s
|
||||
}
|
||||
|
||||
func (s statusRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXPushURL(v string) Response {
|
||||
return HXPushURL(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXLocation(v string) Response {
|
||||
return HXLocation(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXPushUrl(v string) Response {
|
||||
return HXPushUrl(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXRedirect(v string) Response {
|
||||
return HXRedirect(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXRefresh(v string) Response {
|
||||
return HXRefresh(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXReplaceUrl(v string) Response {
|
||||
return HXReplaceUrl(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXReswap(v string) Response {
|
||||
return HXReswap(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXRetarget(v string) Response {
|
||||
return HXRetarget(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXReselect(v string) Response {
|
||||
return HXReselect(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXTrigger(v string) Response {
|
||||
return HXTrigger(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXTriggerAfterSettle(v string) Response {
|
||||
return HXTriggerAfterSettle(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HXTriggerAfterSwap(v string) Response {
|
||||
return HXTriggerAfterSwap(v).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) GetStatus() (int, bool) {
|
||||
return s.code, true
|
||||
}
|
||||
|
||||
func (s statusRes) getHeaders() [][2]string {
|
||||
if s.res != nil {
|
||||
return s.res.getHeaders()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s statusRes) GetRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if s.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return s.res.GetRedirect()
|
||||
}
|
||||
|
||||
func (s statusRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
if s.res == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return s.res.getBody()
|
||||
}
|
||||
|
||||
func (s statusRes) getCookies() []http.Cookie {
|
||||
if s.res != nil {
|
||||
return s.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s statusRes) getContentType() (contentType string, ok bool) {
|
||||
if s.res != nil {
|
||||
return s.res.getContentType()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/consts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func HandleResponses(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
if len(c.Errors) > 0 || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
v, ok := c.Get(responseKey{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
res, ok := v.(Response)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
writeResponse(c, res)
|
||||
}
|
||||
|
||||
func HandleErrors(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
if len(c.Errors) == 0 || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
err ErrorResponse
|
||||
ok bool
|
||||
)
|
||||
for _, e := range c.Errors {
|
||||
if err, ok = GetError(e); ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
var (
|
||||
err error
|
||||
status int
|
||||
statusFound bool
|
||||
)
|
||||
for _, e := range c.Errors {
|
||||
err = errors.Join(err, e)
|
||||
if !statusFound {
|
||||
status, statusFound = mapErrorConstantsToStatus(e)
|
||||
}
|
||||
}
|
||||
|
||||
if !statusFound {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
c.String(status, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
status, ok := err.GetStatus()
|
||||
if !ok {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
if h, ok := err.GetHTML(); ok {
|
||||
c.Status(status)
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.Writer.Write(h)
|
||||
return
|
||||
}
|
||||
|
||||
msg, ok := err.GetMsg()
|
||||
if !ok {
|
||||
msg = err.Error()
|
||||
}
|
||||
c.String(status, msg)
|
||||
}
|
||||
|
||||
func writeResponse(c *gin.Context, res Response) {
|
||||
w := c.Writer
|
||||
|
||||
// w.Header() must be set before ResponseWriter.WriteHeader is called
|
||||
// or redirect is attempted
|
||||
hdrs := w.Header()
|
||||
for _, hdr := range res.getHeaders() {
|
||||
hdrs.Add(hdr[0], hdr[1])
|
||||
}
|
||||
|
||||
for _, ck := range res.getCookies() {
|
||||
hdrs.Add("Set-Cookie", ck.String())
|
||||
}
|
||||
|
||||
if ct, ok := res.getContentType(); ok {
|
||||
hdrs.Set("Content-Type", ct)
|
||||
}
|
||||
|
||||
if code, to, ok := res.GetRedirect(); ok {
|
||||
c.Redirect(code.Int(), to)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// the body is written after the status header, but it's read here
|
||||
// first, because if an error is incurred, an error status header will
|
||||
// need to be written.
|
||||
body, bodySet, err := res.getBody()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to construct response body: %v", err.Error()),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if status, ok := res.GetStatus(); ok {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if bodySet {
|
||||
// will automatically set the status header,
|
||||
// if w.WriteHeader wasn't already called
|
||||
io.Copy(w, body)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func GetStatusFromError(err error) int {
|
||||
status := http.StatusInternalServerError
|
||||
|
||||
if e, ok := GetError(err); ok {
|
||||
if status, ok = e.GetStatus(); !ok {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func mapErrorConstantsToStatus(err error) (int, bool) {
|
||||
for {
|
||||
switch err {
|
||||
case consts.ErrBadRequest:
|
||||
return http.StatusBadRequest, true
|
||||
case consts.ErrNotFound:
|
||||
return http.StatusNotFound, true
|
||||
case consts.ErrConflict:
|
||||
return http.StatusConflict, true
|
||||
default:
|
||||
werr, ok := err.(interface {
|
||||
Unwrap() error
|
||||
})
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
err = werr.Unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"ruben/inventory2/internal/domains/authentication"
|
||||
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/api"
|
||||
"ruben/inventory2/internal/server/auth"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
"ruben/inventory2/internal/server/sse"
|
||||
"ruben/inventory2/internal/server/ui"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
*gin.Engine
|
||||
sse *sse.Queue
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
logger *logging.Logger,
|
||||
contentDir string,
|
||||
rawEvents *raw_events.Store,
|
||||
accts *accounts.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
authr *authentication.Authenticator,
|
||||
) *Router {
|
||||
authM := auth.NewAuth(
|
||||
logger.WithGroup("auth-middleware"),
|
||||
authr,
|
||||
accts,
|
||||
)
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(
|
||||
authM.Identify,
|
||||
response.HandleResponses,
|
||||
response.HandleErrors,
|
||||
)
|
||||
|
||||
// webpage content
|
||||
|
||||
// top level GET is assumed to be for the home page.
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusMovedPermanently, "/ui")
|
||||
})
|
||||
|
||||
ui.Routes(
|
||||
logger.WithGroup("ui"),
|
||||
r.Group("/ui"),
|
||||
"/ui",
|
||||
rawEvents,
|
||||
accts,
|
||||
etsy,
|
||||
authM.Authenticate(),
|
||||
)
|
||||
|
||||
// non-html content: scripts, styles, images, etc
|
||||
r.Use(fileServer("/scripts", contentDir+"/scripts", func(c *gin.Context) {
|
||||
w := c.Writer
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
if path.Ext(c.Request.URL.Path) == ".gz" {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
}
|
||||
}))
|
||||
r.Static("/styles", "./styles")
|
||||
r.Static("/favicon", "./favicon")
|
||||
r.Static("/images", "./images")
|
||||
|
||||
// api endpoints
|
||||
|
||||
// sse setup
|
||||
sq := sse.NewQueue()
|
||||
unp := sq.NewUpdateNotificationPublisher(
|
||||
logger.WithGroup("update.notification.publisher"),
|
||||
func(c *gin.Context) int64 {
|
||||
return auth.GetIdentity(c).Account.AccountID
|
||||
},
|
||||
).Trim("/api")
|
||||
|
||||
api.Routes(
|
||||
r.Group("/api"),
|
||||
logger.WithGroup("/api"),
|
||||
authM,
|
||||
sq,
|
||||
accts,
|
||||
unp,
|
||||
rawEvents,
|
||||
etsy,
|
||||
)
|
||||
|
||||
return &Router{
|
||||
Engine: r,
|
||||
sse: sq,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) RunSSE(ctx context.Context) error {
|
||||
return r.sse.Start(ctx)
|
||||
}
|
||||
|
||||
func fileServer(urlPrefix, dir string, beforeServe func(c *gin.Context)) gin.HandlerFunc {
|
||||
scfs := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
|
||||
return func(c *gin.Context) {
|
||||
r := c.Request
|
||||
if r.URL.Path == urlPrefix || strings.HasPrefix(r.URL.Path, path.Join(urlPrefix, "/")) {
|
||||
if beforeServe != nil {
|
||||
beforeServe(c)
|
||||
}
|
||||
scfs.ServeHTTP(c.Writer, r)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ruben/inventory2/internal/logging"
|
||||
)
|
||||
|
||||
type (
|
||||
UpdateNotificationPublisher struct {
|
||||
log *logging.Logger
|
||||
queue sender
|
||||
getAccountID func(*gin.Context) int64
|
||||
trimBasePath string
|
||||
basePathPattern string
|
||||
}
|
||||
|
||||
// sender is satisfied by *Queue
|
||||
sender interface {
|
||||
Send(ctx context.Context, e Event) error
|
||||
}
|
||||
)
|
||||
|
||||
func (q *Queue) NewUpdateNotificationPublisher(
|
||||
log *logging.Logger,
|
||||
getAccountID func(*gin.Context) int64,
|
||||
) *UpdateNotificationPublisher {
|
||||
return &UpdateNotificationPublisher{
|
||||
log: log,
|
||||
queue: q,
|
||||
getAccountID: getAccountID,
|
||||
}
|
||||
}
|
||||
|
||||
// Trim produces an *UpdateNotificationPublisher with the pathPattern
|
||||
// trimmed from events otherwise published by p.
|
||||
func (p *UpdateNotificationPublisher) Trim(pathPattern string) *UpdateNotificationPublisher {
|
||||
p2 := *p
|
||||
p2.trimBasePath = path.Join(p2.trimBasePath, pathPattern)
|
||||
return &p2
|
||||
}
|
||||
|
||||
// Group produces an *UpdateNotificationPublisher with the pathPattern
|
||||
// appended to the base path used by p, if any, in publishing events.
|
||||
// If no base path was set to p, then pathPattern becomes the base path.
|
||||
func (p *UpdateNotificationPublisher) Group(pathPattern string) *UpdateNotificationPublisher {
|
||||
p2 := *p
|
||||
p2.basePathPattern = path.Join(p2.basePathPattern, pathPattern)
|
||||
return &p2
|
||||
}
|
||||
|
||||
// Publish constructs a gin middleware.
|
||||
// It must used with and after middleware puts in the Identity into the gin.Context.
|
||||
func (p *UpdateNotificationPublisher) Publish(pathPattern string) gin.HandlerFunc {
|
||||
trimBasePathSegs := getPathSegments(p.trimBasePath)
|
||||
trimmedBasePathPattern := path.Join(p.basePathPattern, pathPattern)
|
||||
trimmedBasePathPatternSegs := getPathSegments(trimmedBasePathPattern)
|
||||
fullBasePathPatternSegs := append(trimBasePathSegs, trimmedBasePathPatternSegs...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
reqPathSegs := getPathSegments(c.Request.URL.Path)
|
||||
|
||||
c.Next()
|
||||
if len(c.Errors) > 0 {
|
||||
return
|
||||
}
|
||||
if len(reqPathSegs) < len(fullBasePathPatternSegs) {
|
||||
p.log.Errorf("req path is less that the full path: %v, %v", c.Request.URL.Path, path.Join(p.trimBasePath, p.basePathPattern, pathPattern))
|
||||
return
|
||||
}
|
||||
|
||||
// if the path is a subpath, then emit events along the subpath.
|
||||
|
||||
for i, s := range trimmedBasePathPatternSegs {
|
||||
if isWildcard := s[0] == ':'; isWildcard {
|
||||
continue
|
||||
}
|
||||
|
||||
rs := reqPathSegs[i]
|
||||
if isSubpath := s != rs; isSubpath {
|
||||
continue
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
topEventParts := reqPathSegs[len(trimBasePathSegs):len(fullBasePathPatternSegs)]
|
||||
topEvent := strings.Join(topEventParts, "_")
|
||||
|
||||
events := make([]string, len(reqPathSegs)-len(fullBasePathPatternSegs)+1)
|
||||
events[0] = topEvent
|
||||
parentEvent := topEvent
|
||||
for i, s := range reqPathSegs[len(fullBasePathPatternSegs):] {
|
||||
e := parentEvent + "_" + s
|
||||
events[i+1] = e
|
||||
parentEvent = e
|
||||
}
|
||||
|
||||
acctID := p.getAccountID(c)
|
||||
for _, e := range events {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := p.queue.Send(ctx, Event{
|
||||
AccountID: acctID,
|
||||
Type: e,
|
||||
Data: []byte(fmt.Sprintf(`{"eventType": %q}`, e)),
|
||||
}); err != nil {
|
||||
p.log.Errorf("failed to send sse event to listener: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UpdateNotificationPublisher) Push(ctx context.Context, acctID int64, eventTypes ...string) error {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(eventTypes))
|
||||
|
||||
errs := make([]error, len(eventTypes))
|
||||
for i, e := range eventTypes {
|
||||
i := i
|
||||
e := e
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := p.queue.Send(ctx, Event{
|
||||
AccountID: acctID,
|
||||
Type: e,
|
||||
Data: []byte(fmt.Sprintf(`{"eventType": %q}`, e)),
|
||||
}); err != nil {
|
||||
errs[i] = fmt.Errorf("failed to send sse event to listener: %w", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return errors.Join(errs...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPathSegments(p string) []string {
|
||||
p = path.Clean(p)
|
||||
if p == "" || p == "." || p == "/" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return strings.Split(strings.Trim(p, "/"), "/")
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type (
|
||||
Queue struct {
|
||||
in chan Event
|
||||
out map[int64]map[int]chan Event
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
Event struct {
|
||||
AccountID int64
|
||||
Type string
|
||||
Data []byte
|
||||
}
|
||||
)
|
||||
|
||||
func NewQueue() *Queue {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Queue{
|
||||
in: make(chan Event),
|
||||
out: make(map[int64]map[int]chan Event),
|
||||
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Queue) Start(ctx context.Context) error {
|
||||
defer q.cancel()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// we're done piping events
|
||||
return nil
|
||||
case e := <-q.in:
|
||||
if e.AccountID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// share event with listeners on the account
|
||||
q.lock.Lock()
|
||||
for _, out := range q.out[e.AccountID] {
|
||||
out <- e
|
||||
}
|
||||
q.lock.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Queue) Listen(ctx context.Context, acctID int64, fn func(context.Context, *Event) error) error {
|
||||
// create new out pipe and append it to the queue
|
||||
|
||||
out := make(chan Event, 1)
|
||||
|
||||
q.lock.Lock()
|
||||
outs := q.out[acctID]
|
||||
if outs == nil {
|
||||
outs = make(map[int]chan Event)
|
||||
q.out[acctID] = outs
|
||||
}
|
||||
|
||||
var maxID int
|
||||
for n := range maps.Keys(outs) {
|
||||
maxID = max(maxID, n)
|
||||
}
|
||||
id := maxID + 1
|
||||
|
||||
outs[id] = out
|
||||
q.lock.Unlock()
|
||||
|
||||
// delete the pipe when done listening
|
||||
defer func() {
|
||||
q.lock.Lock()
|
||||
delete(q.out[acctID], id)
|
||||
if len(q.out[acctID]) == 0 {
|
||||
delete(q.out, acctID)
|
||||
}
|
||||
q.lock.Unlock()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-q.ctx.Done():
|
||||
// queue is shut down
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
// done listening to events
|
||||
return nil
|
||||
case e := <-out:
|
||||
// pass event to the caller
|
||||
if err := fn(ctx, &e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Queue) Send(ctx context.Context, e Event) error {
|
||||
select {
|
||||
case <-q.ctx.Done():
|
||||
// the queue has closed
|
||||
return fmt.Errorf("event queue closed: %w", q.ctx.Err())
|
||||
case <-ctx.Done():
|
||||
// sender ran out of time
|
||||
return fmt.Errorf("provided context canceled: %w", ctx.Err())
|
||||
// push the event onto the queue
|
||||
case q.in <- e:
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Event) Write(w http.ResponseWriter) {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"event: %s\ndata: %s\n\n",
|
||||
e.Type,
|
||||
bytes.ReplaceAll(e.Data, []byte("\n"), []byte(" ")),
|
||||
)
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/logging"
|
||||
"ruben/inventory2/internal/server/auth"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
|
||||
"github.com/angelbeltran/templater"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type (
|
||||
webpageRouter struct {
|
||||
log *logging.Logger
|
||||
uiPath string
|
||||
templater *templater.Templater
|
||||
rawEvents *raw_events.Store
|
||||
accts *accounts.Store
|
||||
etsy *etsy_platform.Platform
|
||||
}
|
||||
|
||||
// ErrTemplateNotFound is returned if the reason the template failed to compile
|
||||
// is due to the template not being found.
|
||||
ErrTemplateNotFound struct {
|
||||
err error
|
||||
}
|
||||
)
|
||||
|
||||
func Routes(
|
||||
logger *logging.Logger,
|
||||
r gin.IRoutes,
|
||||
uiPath string,
|
||||
rawEvents *raw_events.Store,
|
||||
accts *accounts.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
authenticate gin.HandlerFunc,
|
||||
) {
|
||||
|
||||
s := &webpageRouter{
|
||||
log: logger,
|
||||
uiPath: uiPath,
|
||||
templater: new(templater.Templater).With(templater.Config{
|
||||
Funcs: func(name string, props map[string]any) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
// parsing
|
||||
"parseInt": func(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
},
|
||||
"parseInt64": func(s string) (int64, error) {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
},
|
||||
"parsePlatform": func(s string) (accounts.Platform, error) {
|
||||
return accounts.NewPlatform(s)
|
||||
},
|
||||
|
||||
// strings
|
||||
"lowerSnakeCase": func(s string) string {
|
||||
return strings.ToLower(strings.Join(strings.Split(s, " "), "_"))
|
||||
},
|
||||
"splitSnakeCase": func(s string) string {
|
||||
return strings.Join(strings.Split(s, "_"), " ")
|
||||
},
|
||||
"capitalize": func(s string) string {
|
||||
ss := strings.Split(s, " ")
|
||||
us := make([]string, len(ss))
|
||||
for i, v := range ss {
|
||||
if len(v) == 0 {
|
||||
us[i] = v
|
||||
} else {
|
||||
us[i] = strings.ToUpper(v[:1]) + v[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(us, " ")
|
||||
},
|
||||
"hasPrefix": func(s, prefix string) bool {
|
||||
return strings.HasPrefix(s, prefix)
|
||||
},
|
||||
|
||||
// arithmetic
|
||||
"addInt": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
"subInt": func(a, b int) int {
|
||||
return a - b
|
||||
},
|
||||
"multInt": func(a, b int) int {
|
||||
return a * b
|
||||
},
|
||||
|
||||
// html
|
||||
"rawHTML": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"rawHTMLAttr": func(s string) template.HTMLAttr {
|
||||
return template.HTMLAttr(s)
|
||||
},
|
||||
"style": func(kvs ...string) (template.HTMLAttr, error) {
|
||||
if len(kvs)%2 != 0 {
|
||||
return "", fmt.Errorf("expected an even number of keys: %d", len(kvs))
|
||||
}
|
||||
parts := make([]string, len(kvs)/2)
|
||||
for i := range parts {
|
||||
parts[i] = fmt.Sprintf("%s: %s;", kvs[2*i], kvs[2*i+1])
|
||||
}
|
||||
return template.HTMLAttr(strings.Join(parts, " ")), nil
|
||||
},
|
||||
|
||||
// json
|
||||
"prettyPrintJSON": func(j json.RawMessage) string {
|
||||
b, err := json.MarshalIndent(j, " ", "")
|
||||
if err != nil {
|
||||
return string(j)
|
||||
}
|
||||
return string(b)
|
||||
},
|
||||
"marshalJSON": json.Marshal,
|
||||
|
||||
// slices
|
||||
"newSlice": func(args ...any) []any {
|
||||
return args
|
||||
},
|
||||
"newHTMLSlice": func(args ...string) []template.HTML {
|
||||
ss := make([]template.HTML, len(args))
|
||||
for i, s := range args {
|
||||
ss[i] = template.HTML(s)
|
||||
}
|
||||
return ss
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
rawEvents: rawEvents,
|
||||
accts: accts,
|
||||
etsy: etsy,
|
||||
}
|
||||
|
||||
r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate))
|
||||
r.GET("/*rest", authenticate, response.Handler(s.serveTemplate))
|
||||
}
|
||||
|
||||
func (s *webpageRouter) redirectToAccountsIfLoggedInWithAnAccount(c *gin.Context) (response.Response, error) {
|
||||
id := auth.GetIdentity(c)
|
||||
if id.Account == nil || id.Account.AccountID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect(fmt.Sprintf("%s/accounts/%d", s.uiPath, id.Account.AccountID)), nil
|
||||
}
|
||||
|
||||
// GET /
|
||||
// compiles the page template or component template matching the url
|
||||
func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error) {
|
||||
// trim the url path prefix before loading the templates
|
||||
c.Request.URL.Path = c.Request.URL.Path[len(s.uiPath):]
|
||||
defer func() {
|
||||
c.Request.URL.Path = s.uiPath + c.Request.URL.Path
|
||||
}()
|
||||
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
|
||||
args := []any{
|
||||
"Request",
|
||||
r,
|
||||
// add services and data here
|
||||
"RawEvents",
|
||||
s.rawEvents.WithContext(ctx),
|
||||
"URLCalc",
|
||||
newURLCalculator(r.URL),
|
||||
"Accounts",
|
||||
s.accts.WithContext(ctx),
|
||||
"Etsy",
|
||||
s.etsy.WithContext(ctx),
|
||||
|
||||
// auth tooling
|
||||
"Identity",
|
||||
auth.GetIdentity(ctx),
|
||||
"Auth",
|
||||
newTemplateAuthenticator(r),
|
||||
}
|
||||
|
||||
b, err := s.templater.Execute(strings.Trim(r.URL.Path, "/"), args...)
|
||||
if err != nil {
|
||||
if isFileNotFoundError(err) || isInvalidWildcardValue(err) {
|
||||
werr := response.NotFound().
|
||||
Wrap(ErrTemplateNotFound{
|
||||
err: err,
|
||||
}).
|
||||
Msg("resource not found")
|
||||
|
||||
nfb, nferr := s.templater.Execute("not-found", args...)
|
||||
if nferr != nil {
|
||||
s.log.Errorf("failed to render not-found page: %v", nferr)
|
||||
return nil, werr
|
||||
}
|
||||
|
||||
return nil, werr.
|
||||
HTML(nfb)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.HTML(b), nil
|
||||
}
|
||||
|
||||
func isInvalidWildcardValue(err error) bool {
|
||||
var te *templater.ErrInvalidWildcardValue
|
||||
return errors.As(err, &te)
|
||||
}
|
||||
|
||||
func isFileNotFoundError(err error) bool {
|
||||
var te *templater.ErrNotTemplateFileFound
|
||||
return errors.As(err, &te)
|
||||
}
|
||||
|
||||
// template tooling
|
||||
|
||||
type URLCalculator struct {
|
||||
url *url.URL
|
||||
}
|
||||
|
||||
func newURLCalculator(u *url.URL) URLCalculator {
|
||||
cpy := *u
|
||||
return URLCalculator{
|
||||
url: &cpy,
|
||||
}
|
||||
}
|
||||
|
||||
func (c URLCalculator) SetQueryParam(k string, v any) string {
|
||||
u := *c.url
|
||||
q := u.Query()
|
||||
q.Set(k, fmt.Sprint(v))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// template authenticator
|
||||
|
||||
type templateAuthenticator struct {
|
||||
req *http.Request
|
||||
}
|
||||
|
||||
func newTemplateAuthenticator(req *http.Request) *templateAuthenticator {
|
||||
return &templateAuthenticator{
|
||||
req: req,
|
||||
}
|
||||
}
|
||||
|
||||
// templateAuthorizationFunc these shouild always return an empty string
|
||||
type templateAuthorizationFunc = func() (string, error)
|
||||
|
||||
func (a *templateAuthenticator) ByMatchingAccountID(acctIDPathPosition int) (string, error) {
|
||||
return "", authorizeByMatchingAccountID(a.req, acctIDPathPosition)
|
||||
}
|
||||
|
||||
func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error {
|
||||
pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/")
|
||||
if len(pathParts) < acctIDPathPosition {
|
||||
return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
|
||||
part := pathParts[acctIDPathPosition-1]
|
||||
acctID, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return response.NotFound().
|
||||
Msgf("account does not exist: %s", part)
|
||||
}
|
||||
|
||||
id := auth.GetIdentity(r.Context())
|
||||
if id.Account == nil || id.Account.AccountID != acctID {
|
||||
return response.Unauthorized().
|
||||
HTML([]byte(fmt.Sprintf(`
|
||||
<section class="text-center">
|
||||
<header class="m-[1em]">
|
||||
<h2>
|
||||
Access Not Granted
|
||||
</h2>
|
||||
|
||||
<a href="/ui%s" class="block m-[1em]">
|
||||
<code>
|
||||
/ui%s
|
||||
</code>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="m-[2em]">
|
||||
<p class="italic font-bold">
|
||||
Sorry, you don't have access to the given page.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="text-center">
|
||||
<a href="/">
|
||||
Return to app
|
||||
</a>
|
||||
</section>
|
||||
`, r.URL, r.URL)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e ErrTemplateNotFound) Error() string {
|
||||
if e.err != nil {
|
||||
return fmt.Sprintf("template not found: %v", e.err)
|
||||
}
|
||||
return fmt.Sprintf("template not found")
|
||||
}
|
||||
|
||||
func (e ErrTemplateNotFound) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
Reference in New Issue
Block a user