save "create sync group" table in database

This commit is contained in:
2026-01-11 05:19:20 -07:00
parent a7c8fe4d64
commit 889aead6b6
28 changed files with 1396 additions and 408 deletions
+126 -1
View File
@@ -4,8 +4,11 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"ruben/inventory2/internal/consts"
"ruben/inventory2/internal/domains/accounts"
"ruben/inventory2/internal/site/middleware"
"ruben/inventory2/internal/site/response"
)
@@ -17,7 +20,7 @@ func (s *Server) createAccount(r *http.Request) (response.Response, error) {
return nil, response.BadRequest().Msg("no email provided")
}
userID := getIdentity(ctx).User.UserID
userID := middleware.GetIdentity(ctx).User.UserID
acct, err := s.accts.CreateAccount(ctx, userID, email)
if err != nil {
@@ -30,3 +33,125 @@ func (s *Server) createAccount(r *http.Request) (response.Response, error) {
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil
}
// POST /accounts/{acctID}/inventory/sync-groups/draft/listings
func (s *Server) createSyncGroupListingDraft(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
orderIndex, err := s.accts.CreateSyncGroupListingDraft(ctx, acctID)
if err != nil {
return nil, response.Errorf("failed to create new listing draft: %w", err)
}
return response.Redirect(
http.StatusSeeOther,
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
), nil
}
// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop
// @platform string
// @shopID string
func (s *Server) setShopInSyncGroupListingDraft(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
if err != nil {
return nil, err
}
platformStr := r.FormValue("platform")
platform, err := accounts.NewPlatform(platformStr)
if err != nil {
return nil, response.BadRequest().
Msgf("unrecognized platform: %s", platformStr)
}
shopID := r.FormValue("shop-id")
if shopID == "" {
return nil, response.BadRequest().
Msg("no shop-id provided")
}
if err := s.accts.SetShopInSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil {
return nil, response.Errorf("failed to set shop: %w", mapConstantErrorsToHTTPErrors(err))
}
return response.Redirect(
http.StatusSeeOther,
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
), nil
}
// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing
func (s *Server) setListingInSyncGroupListingDraft(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
if err != nil {
return nil, err
}
listingID := r.FormValue("listing-id")
if listingID == "" {
return nil, response.BadRequest().
Msg("no listing-id provided")
}
if err := s.accts.SetListingInSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil {
return nil, response.Errorf("failed to set listing: %w", mapConstantErrorsToHTTPErrors(err))
}
return response.Redirect(
http.StatusSeeOther,
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
), nil
}
// DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}
func (s *Server) deleteSyncGroupListingDraft(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
if err != nil {
return nil, err
}
if _, err := s.accts.DeleteSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil {
return nil, response.Errorf("failed to delete listing: %w", mapConstantErrorsToHTTPErrors(err))
}
return response.Status(200), nil
}
// POST /accounts/{acctID}/inventory/sync-groups
func (s *Server) saveNewSyncGroup(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
grp, err := s.accts.SaveNewSyncGroup(ctx, acctID)
if err != nil {
return nil, response.Errorf("failed to save new sync group: %w", mapConstantErrorsToHTTPErrors(err))
}
return response.Redirect(
http.StatusSeeOther,
// TODO: template not implemented
fmt.Sprintf("/accounts/%d/inventory/sync-groups/%d", acctID, grp.SyncGroupID),
), nil
}
func getOrderIndexForSyncGroupListingDraftFromPath(r *http.Request) (int, error) {
orderIndexStr := r.PathValue("orderIndex")
orderIndex, err := strconv.Atoi(orderIndexStr)
if err != nil {
return 0, response.NotFound().
Msgf("no listing draft found at %s", orderIndexStr)
}
return orderIndex, nil
}
+17
View File
@@ -0,0 +1,17 @@
package cookies
import (
"net/http"
"time"
)
func AccessToken(tkn string, expiration time.Time) http.Cookie {
return http.Cookie{
Name: "access_token",
Value: tkn,
Path: "/",
Expires: expiration,
MaxAge: 0, // using Expiration instead
Secure: true,
}
}
@@ -1,8 +1,8 @@
package site
package cookies
import "net/http"
func getExpiredCookie(name string) http.Cookie {
func Expired(name string) http.Cookie {
return http.Cookie{
Name: name,
Path: "/",
+10 -34
View File
@@ -4,15 +4,18 @@ import (
"context"
"fmt"
"net/http"
"ruben/inventory2/internal/domains/authentication"
"ruben/inventory2/internal/site/cookies"
"ruben/inventory2/internal/site/response"
"time"
)
// TODO: use a login/logout server? (prefix: '/auth'?)
// GET /login
func (s *Server) loginPage(r *http.Request) (response.Response, error) {
ctx := r.Context()
u, err := s.newLoginURL(ctx, "/")
u, err := newLoginURL(ctx, s.auth, "/")
if err != nil {
return nil, err
}
@@ -20,31 +23,15 @@ func (s *Server) loginPage(r *http.Request) (response.Response, error) {
return response.TemporaryRedirect(u), nil
}
func (s *Server) newLoginURL(ctx context.Context, targetURI string) (string, error) {
state, err := s.auth.NewState(ctx, targetURI)
func newLoginURL(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) {
state, err := auth.NewState(ctx, targetURI)
if err != nil {
return "", fmt.Errorf("failed to generate random state: %w", err)
}
base64EncodedState := fmt.Sprintf("%x", state[:])
return s.auth.AuthCodeURL(base64EncodedState), nil
}
// POST /login
func (s *Server) login(r *http.Request) (response.Response, error) {
ctx := r.Context()
email := r.FormValue("email")
if email == "" {
return nil, response.BadRequest().Msg("no email provided")
}
acct, err := s.accts.GetAccountByEmail(ctx, email)
if err != nil {
return nil, response.Errorf("failed to create account: %w", err)
}
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil
return auth.AuthCodeURL(base64EncodedState), nil
}
// GET /login/callback
@@ -64,18 +51,7 @@ func (s *Server) loginCallback(r *http.Request) (response.Response, error) {
// set access_token cookie and redirect to a reasonable place
return response.TemporaryRedirect(targetURI).
Cookie(newAccessTokenCookie(accessToken, expiration)), nil
}
func newAccessTokenCookie(tkn string, expiration time.Time) http.Cookie {
return http.Cookie{
Name: "access_token",
Value: tkn,
Path: "/",
Expires: expiration,
MaxAge: 0, // using Expiration instead
Secure: true,
}
Cookie(cookies.AccessToken(accessToken, expiration)), nil
}
// GET /logout
@@ -92,5 +68,5 @@ func (s *Server) logoutPage(r *http.Request) (response.Response, error) {
}
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
Cookie(getExpiredCookie("access_token")), nil
Cookie(cookies.Expired("access_token")), nil
}
@@ -1,4 +1,4 @@
package site
package middleware
import (
"bytes"
@@ -7,26 +7,49 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"ruben/inventory2/internal/consts"
"ruben/inventory2/internal/domains/accounts"
"ruben/inventory2/internal/domains/authentication"
"ruben/inventory2/internal/site/cookies"
"ruben/inventory2/internal/site/response"
)
type identity struct {
AccessToken string
Claims authentication.AccessTokenClaims
User accounts.OAuthUser
Account *accounts.Account
type (
Auth struct {
auth *authentication.Authenticator
newLoginURL LoginURLProviderFunc
accts *accounts.Store
}
Identity struct {
AccessToken string
Claims authentication.AccessTokenClaims
User accounts.OAuthUser
Account *accounts.Account
}
LoginURLProviderFunc = func(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error)
AuthorizationAssertions = response.HandlerFunc
)
func NewAuth(
auth *authentication.Authenticator,
newLoginURL LoginURLProviderFunc,
accts *accounts.Store,
) *Auth {
return &Auth{
auth: auth,
newLoginURL: newLoginURL,
accts: accts,
}
}
func (s *Server) addIdentity(fn response.HandlerFunc) response.HandlerFunc {
func (a *Auth) AddIdentity(fn response.HandlerFunc) response.HandlerFunc {
return func(r *http.Request) (response.Response, error) {
r, err := s.addIdentityToRequest(r)
r, err := a.AddIdentityToRequest(r)
if err != nil {
return nil, err
}
@@ -35,7 +58,7 @@ func (s *Server) addIdentity(fn response.HandlerFunc) response.HandlerFunc {
}
}
func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
func (a *Auth) AddIdentityToRequest(r *http.Request) (*http.Request, error) {
ck, err := r.Cookie("access_token")
if err != nil {
return r, nil
@@ -45,7 +68,7 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
accessToken := ck.Value
claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
if err != nil {
if errors.Is(err, consts.ErrNotFound) {
return r, nil
@@ -58,12 +81,12 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
return r, nil
}
user, acct, err := s.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
if err != nil {
return r, response.Errorf("failed to load user and account defails: %w", err)
}
return r.WithContext(setIdentity(ctx, identity{
return r.WithContext(SetIdentity(ctx, Identity{
AccessToken: accessToken,
Claims: claims,
User: user,
@@ -75,7 +98,7 @@ func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) {
// - then consider doing the same with authorizationAssertions
// auth middleware to verify access_token cookie and set custom claims in the request context
func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions ...authorizationAssertions) response.HandlerFunc {
func (a *Auth) AuthenticateAndAddIdentity(f response.HandlerFunc, assertions ...AuthorizationAssertions) response.HandlerFunc {
return func(r *http.Request) (response.Response, error) {
ck, err := r.Cookie("access_token")
if err != nil {
@@ -87,10 +110,10 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions .
accessToken := ck.Value
claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
if err != nil {
if errors.Is(err, consts.ErrNotFound) {
u, err := s.newLoginURL(ctx, r.URL.String())
u, err := a.newLoginURL(ctx, a.auth, r.URL.String())
if err != nil {
return nil, response.Errorf("failed to generate login url: %w", err)
}
@@ -108,22 +131,22 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions .
// id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in.
const idTokenLifetime = 48 * time.Hour
if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) {
accessToken, expiration, err = s.auth.RefreshAccessToken(ctx, accessToken)
accessToken, expiration, err = a.auth.RefreshAccessToken(ctx, accessToken)
if err != nil {
fmt.Println("failed to refresh access token:", err)
return response.TemporaryRedirect("/").
Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))).
Cookie(getExpiredCookie("access_token")), nil
Cookie(cookies.Expired("access_token")), nil
}
// 'redirect' to same url, to set the new access_token cookie
return response.TemporaryRedirect(r.URL.String()).
Cookie(newAccessTokenCookie(accessToken, expiration)), nil
Cookie(cookies.AccessToken(accessToken, expiration)), nil
}
// add identity info to request context
user, acct, err := s.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
if err != nil {
return nil, response.Errorf("failed to authorize: %w", err)
}
@@ -134,7 +157,7 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions .
}
}
return f(r.WithContext(setIdentity(ctx, identity{
return f(r.WithContext(SetIdentity(ctx, Identity{
AccessToken: accessToken,
Claims: claims,
User: user,
@@ -143,65 +166,15 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions .
}
}
type authorizationAssertions = response.HandlerFunc
// TODO: test this!
func authorizeByMatchingAccountID_tmp(acctIDPathPosition int) authorizationAssertions {
return func(r *http.Request) (response.Response, error) {
return nil, authorizeByMatchingAccountID(r, acctIDPathPosition)
}
}
func (s *Server) getAccessTokenClaims(r *http.Request) (authentication.AccessTokenClaims, bool) {
ck, err := r.Cookie("access_token")
if err != nil {
return authentication.AccessTokenClaims{}, false
}
ctx := r.Context()
claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, ck.Value)
if err != nil {
return authentication.AccessTokenClaims{}, false
}
if expiration.Before(time.Now()) {
return authentication.AccessTokenClaims{}, false
}
return claims, true
}
type identityKey struct{}
// stores identity in request context
func setIdentity(ctx context.Context, id identity) context.Context {
func SetIdentity(ctx context.Context, id Identity) context.Context {
return context.WithValue(ctx, identityKey{}, id)
}
// get identity from request context
func getIdentity(ctx context.Context) identity {
id, _ := ctx.Value(identityKey{}).(identity)
func GetIdentity(ctx context.Context) Identity {
id, _ := ctx.Value(identityKey{}).(Identity)
return id
}
func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error {
pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/")
if len(pathParts) < acctIDPathPosition {
return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path)
}
part := pathParts[acctIDPathPosition-1]
acctID, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return response.NotFound().
Msgf("account does not exist: %s", part)
}
id := getIdentity(r.Context())
if id.Account == nil || id.Account.AccountID != acctID {
return response.Unauthorized().
Msgf("user does not have access to account %d", acctID)
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package response
import "net/http"
type (
Mux struct {
Mux *http.ServeMux
middleware []Middleware
}
Middleware = func(HandlerFunc) HandlerFunc
)
func NewMux(ms ...Middleware) *Mux {
return &Mux{
Mux: http.NewServeMux(),
middleware: ms,
}
}
func (m *Mux) Handle(pattern string, fn HandlerFunc) {
for _, mw := range m.middleware {
prev := fn
fn = mw(func(r *http.Request) (Response, error) {
return prev(r)
})
}
m.Mux.Handle(pattern, Handler(fn))
}
func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.Mux.ServeHTTP(w, r)
}
+5 -1
View File
@@ -48,6 +48,10 @@ func Write(w http.ResponseWriter, r *http.Request, res Response) {
}
func WriteError(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), GetStatusFromError(err))
}
func GetStatusFromError(err error) int {
status := http.StatusInternalServerError
if e, ok := GetError(err); ok {
@@ -56,5 +60,5 @@ func WriteError(w http.ResponseWriter, err error) {
}
}
http.Error(w, err.Error(), status)
return status
}
+102 -29
View File
@@ -11,21 +11,24 @@ import (
"github.com/angelbeltran/templater"
"ruben/inventory2/internal/consts"
"ruben/inventory2/internal/domains/accounts"
"ruben/inventory2/internal/domains/authentication"
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/site/middleware"
"ruben/inventory2/internal/site/response"
)
type Server struct {
http.Handler
contentDir string
templater *templater.Templater
rawEvents *raw_events.Store
accts *accounts.Store
etsy *etsy_platform.Platform
auth *authentication.Authenticator
contentDir string
templater *templater.Templater
rawEvents *raw_events.Store
accts *accounts.Store
etsy *etsy_platform.Platform
auth *authentication.Authenticator
authMiddleware *middleware.Auth
}
func NewServer(
@@ -35,14 +38,28 @@ func NewServer(
etsy *etsy_platform.Platform,
auth *authentication.Authenticator,
) *Server {
mux := http.NewServeMux()
mux := response.NewMux(func(fn response.HandlerFunc) response.HandlerFunc {
return func(r *http.Request) (response.Response, error) {
res, err := fn(r)
if err != nil {
status := response.GetStatusFromError(err)
// TODO: get better logger
fmt.Printf("[ERROR]: %d: %s; %s\n", status, r.URL, err)
}
return res, err
}
})
s := &Server{
Handler: mux,
contentDir: contentDir,
templater: templater.NewTemplater(
contentDir+"/templates",
func() template.FuncMap {
return template.FuncMap{
// paths
"buildSitePath": func(parts ...any) string {
strParts := make([]string, len(parts))
for i, p := range parts {
@@ -60,18 +77,30 @@ func NewServer(
return strings.Split(strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/"), "/")
},
"prettyPrintJSON": func(j json.RawMessage) string {
b, err := json.MarshalIndent(j, " ", "")
if err != nil {
return string(j)
// params
"addPathParam": func(k string, v any, args map[string]any) (map[string]any, error) {
pathParams, ok := args["PathParams"].(map[string]string)
if !ok {
return nil, fmt.Errorf("PathParams no set are args: %v", args)
}
return string(b)
pathParams[k] = fmt.Sprint(v)
return args, nil
},
// parsing
"parseInt": func(s string) (int, error) {
return strconv.Atoi(s)
},
"parseInt64": func(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
},
"parsePlatform": func(s string) (accounts.Platform, error) {
return accounts.NewPlatform(s)
},
// arithmetic
"addInt": func(a, b int) int {
return a + b
},
@@ -81,6 +110,15 @@ func NewServer(
"multInt": func(a, b int) int {
return a * b
},
// json
"prettyPrintJSON": func(j json.RawMessage) string {
b, err := json.MarshalIndent(j, " ", "")
if err != nil {
return string(j)
}
return string(b)
},
}
},
),
@@ -88,39 +126,74 @@ func NewServer(
accts: accts,
etsy: etsy,
auth: auth,
authMiddleware: middleware.NewAuth(
auth,
newLoginURL,
accts,
),
}
// api routes
withAuth := func(fn response.HandlerFunc) response.HandlerFunc {
return s.authMiddleware.AuthenticateAndAddIdentity(fn)
}
mux.Handle("GET /login", response.Handler(s.loginPage))
mux.Handle("GET /login/callback", response.Handler(s.loginCallback))
mux.Handle("GET /logout", response.Handler(s.logoutPage))
mux.Handle("POST /accounts", response.Handler(s.authenticateAndAddIdentity(s.createAccount)))
// login
// TODO: eliminate once no longer used.
mux.HandleFunc("POST /login", response.Handler(s.login))
mux.Handle("GET /login", s.loginPage)
mux.Handle("GET /login/callback", s.loginCallback)
mux.Handle("GET /logout", s.logoutPage)
// /accounts
mux.Handle("POST /accounts", withAuth(s.createAccount))
mux.Handle("POST /accounts/{acctID}/inventory/sync-groups/draft/listings", withAuth(s.createSyncGroupListingDraft))
mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop", withAuth(s.setShopInSyncGroupListingDraft))
mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing", withAuth(s.setListingInSyncGroupListingDraft))
mux.Handle("DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}", withAuth(s.deleteSyncGroupListingDraft))
mux.Handle("POST /accounts/{acctID}/inventory/sync-groups", withAuth(s.saveNewSyncGroup))
// webpage content
// non-html content: scripts, styles, images, etc
scfs := http.FileServer(http.Dir(contentDir + "/scripts"))
mux.Handle("GET /scripts/", http.StripPrefix("/scripts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Mux.Handle("GET /scripts/", http.StripPrefix("/scripts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript")
if path.Ext(r.URL.Path) == ".gz" {
w.Header().Set("Content-Encoding", "gzip")
}
scfs.ServeHTTP(w, r)
})))
mux.Handle("GET /styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(contentDir+"/styles"))))
mux.Mux.Handle("GET /styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(contentDir+"/styles"))))
// webpages
// html
// all non-authenticated webpages
mux.HandleFunc("GET /{$}", response.Handler(s.addIdentity(s.serveTemplates)))
// all authenticated webpages
mux.HandleFunc("GET /", response.Handler(s.authenticateAndAddIdentity(s.serveTemplates)))
s.Handler = mux
// non-authenticated
mux.Handle("GET /{$}", s.authMiddleware.AddIdentity(s.serveTemplates))
// authenticated
mux.Handle("GET /", withAuth(s.serveTemplates))
return s
}
func mapConstantErrorsToHTTPErrors(err error) error {
cerr := err
for cerr != nil {
switch cerr {
case consts.ErrNotFound:
return response.NotFound()
case consts.ErrConflict:
return response.Conflict()
}
uerr, ok := cerr.(interface {
Unwrap() error
})
if !ok {
return err
}
cerr = uerr.Unwrap()
}
return err
}
+31 -8
View File
@@ -13,10 +13,9 @@
--text-lg--line-height: calc(1.75 / 1.125);
--text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25);
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-8xl: 6rem;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--radius-lg: var(--radius);
@@ -196,6 +195,9 @@
.flex {
display: flex;
}
.table {
display: table;
}
.min-h-full {
min-height: 100%;
}
@@ -235,6 +237,9 @@
.gap-y-\[2em\] {
row-gap: 2em;
}
.overflow-x-auto {
overflow-x: auto;
}
.overflow-x-scroll {
overflow-x: scroll;
}
@@ -314,6 +319,21 @@
}
}
}
.disabled\:cursor-not-allowed {
&:disabled {
cursor: not-allowed;
}
}
.disabled\:bg-accent-secondary {
&:disabled {
background-color: var(--accent-secondary);
}
}
.disabled\:no-underline {
&:disabled {
text-decoration-line: none;
}
}
}
@layer base {
select {
@@ -376,6 +396,7 @@
--muted: var(--base-100);
--muted-foreground: var(--base-600);
--accent: var(--base-100);
--accent-secondary: var(--base-300);
--accent-foreground: var(--base-800);
--destructive: oklch(0.577 0.245 27.325);
--border: var(--base-200);
@@ -417,6 +438,7 @@
--muted: var(--base-800);
--muted-foreground: var(--base-300);
--accent: var(--base-800);
--accent-secondary: var(--base-600);
--accent-foreground: var(--base-200);
--destructive: oklch(0.704 0.191 22.216);
--border: var(--base-800);
@@ -462,6 +484,7 @@
--muted: var(--base-800);
--muted-foreground: var(--base-300);
--accent: var(--base-800);
--accent-secondary: var(--base-600);
--accent-foreground: var(--base-200);
--destructive: oklch(0.704 0.191 22.216);
--border: var(--base-800);
@@ -491,23 +514,23 @@
font-weight: var(--display-weight);
}
h1 {
font-size: var(--text-5xl);
font-size: var(--text-8xl);
}
h2 {
font-size: var(--text-4xl);
font-size: var(--text-5xl);
}
h3 {
font-size: var(--text-3xl);
}
h4 {
font-size: var(--text-2xl);
}
h5 {
font-size: var(--text-xl);
}
h6 {
h5 {
font-size: var(--text-lg);
}
h6 {
font-size: var(--text-md);
}
ul, ol {
list-style: none;
}
+10 -6
View File
@@ -60,6 +60,7 @@
--muted: var(--base-100);
--muted-foreground: var(--base-600);
--accent: var(--base-100);
--accent-secondary: var(--base-300);
--accent-foreground: var(--base-800);
--destructive: oklch(0.577 0.245 27.325);
--border: var(--base-200);
@@ -104,6 +105,7 @@
--muted: var(--base-800);
--muted-foreground: var(--base-300);
--accent: var(--base-800);
--accent-secondary: var(--base-600);
--accent-foreground: var(--base-200);
--destructive: oklch(0.704 0.191 22.216);
--border: var(--base-800);
@@ -152,6 +154,7 @@
--muted: var(--base-800);
--muted-foreground: var(--base-300);
--accent: var(--base-800);
--accent-secondary: var(--base-600);
--accent-foreground: var(--base-200);
--destructive: oklch(0.704 0.191 22.216);
--border: var(--base-800);
@@ -236,6 +239,7 @@
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent-secondary: var(--accent-secondary);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
@@ -268,23 +272,23 @@
}
h1 {
font-size: var(--text-5xl);
font-size: var(--text-8xl);
}
h2 {
font-size: var(--text-4xl);
font-size: var(--text-5xl);
}
h3 {
font-size: var(--text-3xl);
}
h4 {
font-size: var(--text-2xl);
}
h5 {
font-size: var(--text-xl);
}
h6 {
h5 {
font-size: var(--text-lg);
}
h6 {
font-size: var(--text-md);
}
ul, ol {
list-style: none;
+30 -32
View File
@@ -1,16 +1,16 @@
package site
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"ruben/inventory2/internal/site/middleware"
"ruben/inventory2/internal/site/response"
"strconv"
"strings"
)
@@ -65,7 +65,7 @@ func (s *Server) getTemplateNameAndArgs(r *http.Request, templateDir string) (na
Account *accounts.Account
*/
"Identity",
getIdentity(r.Context()),
middleware.GetIdentity(r.Context()),
"Auth",
newTemplateAuthenticator(r),
}
@@ -136,37 +136,13 @@ func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(file
}
func (s *Server) handleTemplateError(err error, templateArgs ...any) (response.Response, error) {
code := getHTTPStatusCode(err)
if code == http.StatusNotFound ||
code == http.StatusForbidden ||
code == http.StatusUnauthorized ||
isFileNotFoundError(err) {
b, err := s.templater.ExecutePage("not-found", templateArgs...)
if err != nil {
fmt.Println("failed to render not found page:", err)
return nil, response.NotFound().
Wrap(err).
Msg("resource not found")
}
return response.Body(io.NopCloser(bytes.NewBuffer(b))), nil
if isFileNotFoundError(err) {
return nil, response.NotFound().
Wrap(err).
Msg("resource not found")
}
if code == http.StatusConflict {
b, err := s.templater.ExecutePage("conflict", templateArgs...)
if err != nil {
fmt.Println("failed to render conflict page:", err)
return nil, response.Conflict().
Wrap(err).
Msg("conflict")
}
return response.Body(io.NopCloser(bytes.NewBuffer(b))), nil
}
return nil, fmt.Errorf("failed to render page: %w", err)
return nil, err
}
func isFileNotFoundError(err error) bool {
@@ -230,3 +206,25 @@ type templateAuthorizationFunc = func() (string, error)
func (a *templateAuthenticator) ByMatchingAccountID(acctIDPathPosition int) (string, error) {
return "", authorizeByMatchingAccountID(a.req, acctIDPathPosition)
}
func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error {
pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/")
if len(pathParts) < acctIDPathPosition {
return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path)
}
part := pathParts[acctIDPathPosition-1]
acctID, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return response.NotFound().
Msgf("account does not exist: %s", part)
}
id := middleware.GetIdentity(r.Context())
if id.Account == nil || id.Account.AccountID != acctID {
return response.Unauthorized().
Msgf("user does not have access to account %d", acctID)
}
return nil
}
@@ -0,0 +1,126 @@
{{/* TODO: make the save button disabled based on an api call */}}
{{/* .Identity.Account.AccountID, .Accounts */}}
{{- $dot := or .dot . -}}
{{- $acctID := $dot.Identity.Account.AccountID }}
{{- $orderIndex := or $dot.OrderIndex (parseInt $dot.PathParams.orderIndex) -}}
{{- $entry := $dot.Accounts.GetSyncGroupListingDraft $acctID $orderIndex }}
{{- $selectedShopPlatform := $entry.Platform }}
{{- $selectedShopID := $entry.ShopID }}
{{- $selectedListingID := $entry.ListingID }}
<tr _="
init
set @data-filled-out to false
def setFilledOut(val)
set @data-filled-out to val
send rowUpdated() to closest <tbody/>
end
on setListing(listing)
call setFilledOut(true)
on resetListing
call setFilledOut(false)
"
>
<td>
{{- $shops := $dot.Accounts.GetShops $acctID -}}
<select
class="min-w-fit cursor-pointer"
hx-put="/accounts/{{$acctID}}/inventory/sync-groups/draft/listings/{{$orderIndex}}/shop"
hx-vals='js:{
platform: event.target.value.replace(/-[^ ]*/, ""),
"shop-id": event.target.value.replace(/[^ ]*-/, "")
}'
hx-target="closest tr"
hx-swap="outerHTML"
_="
on change
send resetListing() to closest <tr/>
"
>
<option disabled {{- if not $selectedShopID }}selected{{- end }}>- Shops -</option>
{{- range $shop := $shops }}
{{- $isSelectedShop := and
(eq $selectedShopPlatform $shop.Platform)
(eq $selectedShopID $shop.ShopID)
}}
<option
value="{{$shop.Platform}}-{{$shop.ShopID}}"
{{- if $isSelectedShop }}selected{{- end }}
>
{{ $shop.Name }} - {{$shop.Platform}} - {{$shop.ShopID}}
</option>
{{- end }}
</select>
</td>
<td>
{{- $selectedListing := "" }}
{{- if $selectedShopID }}
{{- $listings := $dot.Accounts.GetListingsForShop $acctID $selectedShopPlatform $selectedShopID }}
{{- if $listings }}
<select
class="min-w-fit cursor-pointer"
hx-put="/accounts/{{$acctID}}/inventory/sync-groups/draft/listings/{{$orderIndex}}/listing"
hx-vals='js:{
"listing-id": event.target.value,
}'
hx-target="closest tr"
hx-swap="outerHTML"
_="
on input
set dataset to (my selectedOptions)[0].dataset
send setListing(listing: dataset) to the closest <tr/>
"
>
<option disabled {{if not $selectedListingID}}selected{{end}}>- Listings -</option>
{{- range $i, $listing := $listings }}
{{- if eq $selectedListingID $listing.ListingID }}
{{ $selectedListing = $listing }}
{{- end }}
<option
value="{{$listing.ListingID}}"
{{if eq $selectedListingID $listing.ListingID}}selected{{end}}
data-name="{{ $listing.Name }}"
data-sku="{{ $listing.SKU }}"
data-description="{{ $listing.Description }}"
data-count="{{ $listing.Count }}"
>
{{ $listing.Name }}
</option>
{{- end }}
</select>
{{- else }}
no listings found
{{- end }}
{{- else }}
-
{{- end }}
</td>
<td data-id="sku">
{{if $selectedListing}}{{$selectedListing.SKU}}{{else}}-{{end}}
</td>
<td data-id="description">
{{if $selectedListing}}{{$selectedListing.Description}}{{else}}-{{end}}
</td>
<td data-id="remove">
{{ componentBody "button-dev"
"HXDelete" (printf "/accounts/%d/inventory/sync-groups/draft/listings/%d" $acctID $orderIndex)
"HXTarget" "closest tr"
"HXSwap" "outerHTML"
"Text" "Remove"
}}
</td>
</tr>
@@ -1,5 +1,3 @@
{{/* .Identity.Account.AccountID | .PathParams.acctID | .AccountID */}}
{{- $dot := or .dot . }}
{{- .Auth.ByMatchingAccountID 2 }}
@@ -16,7 +14,7 @@
{{- $stores := $dot.Accounts.GetShops $acctID -}}
<table id="inventory-table-2" class="max-w-full overflow-x-scroll">
<table id="inventory-table-2" class="max-w-full overflow-x-auto">
<thead>
<tr>
<th>
@@ -36,11 +34,36 @@
</th>
</tr>
</thead>
<tbody>
{{/* TODO: list existing row */}}
{{- componentBody "accounts/{acctID}/inventory/sync-table-v2/new-row" "dot" $dot }}
{{/* TODO: save the saved rows */}}
<tbody
_="
init
set element allRowsFilledOut to false
set element numRows to 0
def checkIfAllRowsAreFilledOut()
set rows to my children
set element allRowsFilledOut to true
set element numRows to rows.length
for row in rows
set filledOut to row.dataset.filledOut is 'true'
if not filledOut then
set element allRowsFilledOut to false
break
end
end
end
on rowUpdated
checkIfAllRowsAreFilledOut()
send setDisabled(disabled: not allRowsFilledOut or numRows < 2) to #create-sync-group-button
"
>
{{- $listings := $dot.Accounts.GetSyncGroupListingDrafts $acctID }}
{{- range $i, $listing := $listings }}
{{- componentBody "accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}"
"dot" ($dot | addPathParam "orderIndex" $i)
}}
{{- end }}
</tbody>
<tfoot>
<tr>
@@ -1,64 +0,0 @@
{{/* .Identity.Account.AccountID, .Accounts */}}
{{- $dot := or .dot . -}}
{{- $acctID := $dot.Identity.Account.AccountID }}
{{- $shops := $dot.Accounts.GetShops $acctID -}}
<tr _="
on setListing(listing)
set the innerHTML of (the last <td[data-id=sku]/> in me) to listing.sku
set the innerHTML of (the last <td[data-id=description]/> in me) to listing.description
-- TODO: only remove if there isn't already a 'new row'
remove @disabled from (the <button/> in last <td[data-id=save]/> in me)
on resetListing
set the innerHTML of (the last <td[data-id=sku]/> in me) to '-'
set the innerHTML of (the last <td[data-id=description]/> in me) to '-'
add @disabled to (the <button/> in last <td[data-id=save]/> in me)
"
>
<td>
<select
class="min-w-fit cursor-pointer"
hx-get="/accounts/{{$acctID}}/platforms/{platform}/shops/{shopID}/listing-select"
hx-vals='js:{
platform: event.target.value.replace(/-[^ ]*/, ""),
shopID: event.target.value.replace(/[^ ]*-/, "")
}'
hx-target="next td"
_="
on change
log event.target.value
send resetListing() to closest <tr/>
"
>
<option disabled selected>- Shops -</option>
{{- range $shop := $shops }}
<option value="{{$shop.Platform}}-{{$shop.ShopID}}">
{{ $shop.Name }} - {{$shop.Platform}} - {{$shop.ShopID}}
</option>
{{- end }}
</select>
</td>
<td>
-
</td>
<td data-id="sku">
-
</td>
<td data-id="description">
-
</td>
<td data-id="save">
<button
disabled
hx-get="/accounts/{{$acctID}}/inventory/sync-table-v2/new-row"
hx-target="closest tr"
hx-swap="afterend"
>
Save
</button>
</td>
</tr>
@@ -1,3 +1,4 @@
{{/* TODO: delete when done with the newer draft */}}
{{- $dot := or .dot .}}
{{- $dot.Auth.ByMatchingAccountID 2 }}
@@ -8,7 +9,6 @@
<tr>
{{- range $shop := $shops }}
{{- $listings := $dot.Accounts.GetListingsForShop $acctID $shop.ShopID }}
<td>
<select
class="min-w-fit cursor-pointer"
@@ -30,6 +30,7 @@
"
>
<option disabled selected>- Listings -</option>
{{/*- $listings := $dot.Accounts.GetListingsForShop $acctID $shop.ShopID }}
{{- range $listing := $listings }}
<option
value="{{$listing.ListingID}}"
@@ -40,7 +41,7 @@
>
{{ $listing.Name }}
</option>
{{- end }}
{{- end */}}
</select>
</td>
<td>
@@ -1,29 +0,0 @@
{{/* .ShopID: default to path param "shopID" */}}
{{- $shopID := or (and .PathParams .PathParams.shopID) .ShopID }}
{{ $acctID := .Identity.Account.AccountID }}
<select
class="min-w-fit cursor-pointer"
_="
on input
set dataset to (my selectedOptions)[0].dataset
send setListing(listing: dataset) to the closest <tr/>
"
>
{{- $listings := .Accounts.GetListingsForShop $acctID $shopID }}
{{- $dot := . }}
<option disabled selected>- Listings -</option>
{{- range $listing := $listings }}
<option
value="{{$listing.SKU}}"
data-name="{{ $listing.Name }}"
data-sku="{{ $listing.SKU }}"
data-description="{{ $listing.Description }}"
data-count="{{ $listing.Count }}"
>
{{ $listing.Name }}
</option>
{{- end }}
</select>
@@ -0,0 +1,45 @@
{{/* .HXGet | .HXPost | .HXDelete, .HXTarget, .HXSwap, .HXVals, ._, .Text, .ID, .Class, .Disabled */}}
<button
class="
border-thin
bg-card
rounded-sm
p-[0.5em]
font-semibold
cursor-pointer
hover:underline
hover:bg-accent
disabled:bg-accent-secondary
disabled:cursor-not-allowed
disabled:no-underline
{{.Class}}
"
{{- if .ID }}
id="{{.ID}}"
{{- end }}
{{- if .HXGet }}
hx-get="{{.HXGet}}"
{{- else if .HXPost }}
hx-post="{{.HXPost}}"
{{- else if .HXDelete }}
hx-delete="{{.HXDelete}}"
{{- end }}
{{- if .HXTarget }}
hx-target="{{.HXTarget}}"
{{- end }}
{{- if .HXSwap }}
hx-swap="{{.HXSwap}}"
{{- end }}
{{- if .HXVals }}
hx-vals="{{.HXVals}}"
{{- end }}
{{- if .Disabled}}
disabled
{{- end}}
{{- if ._ }}
_="{{ ._ }}"
{{- end }}
>
{{.Text}}
</button>
@@ -1,19 +1,55 @@
{{- .Auth.ByMatchingAccountID 2 }}
{{- $acctID := .Identity.Account.AccountID }}
{{- $stores := .Accounts.GetShops $acctID -}}
{{- define "title" }} Inventory++ {{ end }}
<h1 class="text-center mb-[0.5em]">Inventory</h1>
<h2 class="text-center mb-[0.5em]">Sync Stores</h2>
{{ $acctID := .Identity.Account.AccountID }}
{{ $stores := .Accounts.GetShops $acctID }}
<h2 class="text-center mb-[0.5em]">Synced Listings</h2>
<section class="w-full flex flex-col items-center">
<h3>
Draft 2
Create a Sync Group
</h3>
<h4>
(Draft 2)
</h4>
{{ componentBody "accounts/{acctID}/inventory/sync-table-v2" "dot" . }}
<div>
{{ componentBody "button-dev"
"HXPost" (printf "/accounts/%d/inventory/sync-groups/draft/listings" $acctID)
"HXTarget" "previous table > tbody"
"HXSwap" "beforeend"
"Class" "mt-[1em] mb-[1em]"
"Text" "Add Listing"
"_" `
on click
send rowUpdated to the first <tbody/> in #inventory-table-2
`
}}
{{/* TODO: enable the button when all listings are filled and there are at least two listings */}}
{{/* TODO: save the listings as a sync group on click */}}
{{ componentBody "button-dev"
"ID" "create-sync-group-button"
"HXPost" (printf "/accounts/%d/sync-groups" $acctID)
"Class" "mt-[1em] mb-[1em]"
"Text" "Save Sync Group"
"Disabled" true
"_" `
on setDisabled(disabled)
if disabled then
add @disabled to me
else
remove @disabled from me
end
`
}}
</div>
</section>
<section class="w-full flex flex-col items-center">
@@ -28,12 +64,11 @@
</section>
<section class="w-full flex justify-center mt-[1em]">
<button
class="border-thin bg-card rounded-sm p-[0.5em] font-semibold cursor-pointer hover:underline hover:bg-accent"
hx-get="/accounts/{{$acctID}}/inventory/sync-table/new-row"
hx-target="#inventory-table > tbody"
hx-swap="beforeend"
>
Add Row
</button>
{{ componentBody "button-dev"
"HXGet" (printf "/accounts/%d/inventory/sync-table/new-row" $acctID)
"HXTarget" "#inventory-table > tbody"
"HXSwap" "beforeend"
"Class" "mt-[1em] mb-[1em]"
"Text" "Add Row"
}}
</section>