diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2a2754a9..87730fb5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -35,6 +35,6 @@ // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "node", "features": { - "golang": "1.20" + "golang": "1.21" } } diff --git a/.github/workflows/partial-backend.yaml b/.github/workflows/partial-backend.yaml index c027ff46..0af2eaaa 100644 --- a/.github/workflows/partial-backend.yaml +++ b/.github/workflows/partial-backend.yaml @@ -12,7 +12,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.20" + go-version: "1.21" - name: Install Task uses: arduino/setup-task@v1 diff --git a/.github/workflows/partial-frontend.yaml b/.github/workflows/partial-frontend.yaml index c26c5f40..cdf51b74 100644 --- a/.github/workflows/partial-frontend.yaml +++ b/.github/workflows/partial-frontend.yaml @@ -46,7 +46,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.20" + go-version: "1.21" - uses: actions/setup-node@v4 with: diff --git a/backend/app/api/handlers/v1/controller.go b/backend/app/api/handlers/v1/controller.go index 34a75423..25f6aab2 100644 --- a/backend/app/api/handlers/v1/controller.go +++ b/backend/app/api/handlers/v1/controller.go @@ -127,6 +127,22 @@ func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) errchain.Hand } } +// HandleCurrency godoc +// +// @Summary Currency +// @Tags Base +// @Produce json +// @Success 200 {object} currencies.Currency +// @Router /v1/currency [GET] +func (ctrl *V1Controller) HandleCurrency() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + // Set Cache for 10 Minutes + w.Header().Set("Cache-Control", "max-age=600") + + return server.JSON(w, http.StatusOK, ctrl.svc.Currencies.Slice()) + } +} + func (ctrl *V1Controller) HandleCacheWS() errchain.HandlerFunc { m := melody.New() diff --git a/backend/app/api/handlers/v1/v1_ctrl_group.go b/backend/app/api/handlers/v1/v1_ctrl_group.go index 45a8557d..0d26869b 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_group.go +++ b/backend/app/api/handlers/v1/v1_ctrl_group.go @@ -6,6 +6,7 @@ import ( "github.com/hay-kot/homebox/backend/internal/core/services" "github.com/hay-kot/homebox/backend/internal/data/repo" + "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/internal/web/adapters" "github.com/hay-kot/httpkit/errchain" ) @@ -52,6 +53,14 @@ func (ctrl *V1Controller) HandleGroupGet() errchain.HandlerFunc { func (ctrl *V1Controller) HandleGroupUpdate() errchain.HandlerFunc { fn := func(r *http.Request, body repo.GroupUpdate) (repo.Group, error) { auth := services.NewContext(r.Context()) + + ok := ctrl.svc.Currencies.IsSupported(body.Currency) + if !ok { + return repo.Group{}, validate.NewFieldErrors( + validate.NewFieldError("currency", "currency '" + body.Currency + "' is not supported"), + ) + } + return ctrl.svc.Group.UpdateGroup(auth, body) } diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 2c270176..13892854 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "fmt" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/hay-kot/homebox/backend/internal/core/currencies" "github.com/hay-kot/homebox/backend/internal/core/services" "github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus" "github.com/hay-kot/homebox/backend/internal/data/ent" @@ -126,12 +128,40 @@ func run(cfg *config.Config) error { return err } + collectFuncs := []currencies.CollectorFunc{ + currencies.CollectDefaults(), + } + + if cfg.Options.CurrencyConfig != "" { + log.Info(). + Str("path", cfg.Options.CurrencyConfig). + Msg("loading currency config file") + + content, err := os.ReadFile(cfg.Options.CurrencyConfig) + if err != nil { + log.Fatal(). + Err(err). + Str("path", cfg.Options.CurrencyConfig). + Msg("failed to read currency config file") + } + + collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content))) + } + + currencies, err := currencies.CollectionCurrencies(collectFuncs...) + if err != nil { + log.Fatal(). + Err(err). + Msg("failed to collect currencies") + } + app.bus = eventbus.New() app.db = c app.repos = repo.New(c, app.bus, cfg.Storage.Data) app.services = services.New( app.repos, services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID), + services.WithCurrencies(currencies), ) // ========================================================================= diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 62a260b7..7ffc3135 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -64,6 +64,8 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR BuildTime: buildTime, }))) + r.Get(v1Base("/currencies"), chain.ToHandlerFunc(v1Ctrl.HandleCurrency())) + providers := []v1.AuthProvider{ providers.NewLocalProvider(a.services.User), } diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index bbbe3767..d5a8b711 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -150,6 +150,25 @@ const docTemplate = `{ } } }, + "/v1/currency": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Base" + ], + "summary": "Currency", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/currencies.Currency" + } + } + } + } + }, "/v1/groups": { "get": { "security": [ @@ -410,6 +429,16 @@ const docTemplate = `{ "description": "location Ids", "name": "locations", "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "parent Ids", + "name": "parentIds", + "in": "query" } ], "responses": { @@ -1574,7 +1603,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.ApiSummary" + "$ref": "#/definitions/v1.APISummary" } } } @@ -1645,6 +1674,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/v1.LoginForm" } + }, + { + "type": "string", + "description": "auth provider", + "name": "provider", + "in": "query" } ], "responses": { @@ -1823,6 +1858,23 @@ const docTemplate = `{ } }, "definitions": { + "currencies.Currency": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "local": { + "type": "string" + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, "repo.DocumentOut": { "type": "object", "properties": { @@ -1998,12 +2050,6 @@ const docTemplate = `{ "$ref": "#/definitions/repo.ItemAttachment" } }, - "children": { - "type": "array", - "items": { - "$ref": "#/definitions/repo.ItemSummary" - } - }, "createdAt": { "type": "string" }, @@ -2181,8 +2227,7 @@ const docTemplate = `{ "type": "boolean" }, "assetId": { - "type": "string", - "example": "0" + "type": "string" }, "description": { "type": "string" @@ -2736,15 +2781,7 @@ const docTemplate = `{ } } }, - "v1.ActionAmountResult": { - "type": "object", - "properties": { - "completed": { - "type": "integer" - } - } - }, - "v1.ApiSummary": { + "v1.APISummary": { "type": "object", "properties": { "allowRegistration": { @@ -2773,6 +2810,14 @@ const docTemplate = `{ } } }, + "v1.ActionAmountResult": { + "type": "object", + "properties": { + "completed": { + "type": "integer" + } + } + }, "v1.Build": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index e2d98fec..2ff12952 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -143,6 +143,25 @@ } } }, + "/v1/currency": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Base" + ], + "summary": "Currency", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/currencies.Currency" + } + } + } + } + }, "/v1/groups": { "get": { "security": [ @@ -403,6 +422,16 @@ "description": "location Ids", "name": "locations", "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "parent Ids", + "name": "parentIds", + "in": "query" } ], "responses": { @@ -1567,7 +1596,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.ApiSummary" + "$ref": "#/definitions/v1.APISummary" } } } @@ -1638,6 +1667,12 @@ "schema": { "$ref": "#/definitions/v1.LoginForm" } + }, + { + "type": "string", + "description": "auth provider", + "name": "provider", + "in": "query" } ], "responses": { @@ -1816,6 +1851,23 @@ } }, "definitions": { + "currencies.Currency": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "local": { + "type": "string" + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, "repo.DocumentOut": { "type": "object", "properties": { @@ -1991,12 +2043,6 @@ "$ref": "#/definitions/repo.ItemAttachment" } }, - "children": { - "type": "array", - "items": { - "$ref": "#/definitions/repo.ItemSummary" - } - }, "createdAt": { "type": "string" }, @@ -2174,8 +2220,7 @@ "type": "boolean" }, "assetId": { - "type": "string", - "example": "0" + "type": "string" }, "description": { "type": "string" @@ -2729,15 +2774,7 @@ } } }, - "v1.ActionAmountResult": { - "type": "object", - "properties": { - "completed": { - "type": "integer" - } - } - }, - "v1.ApiSummary": { + "v1.APISummary": { "type": "object", "properties": { "allowRegistration": { @@ -2766,6 +2803,14 @@ } } }, + "v1.ActionAmountResult": { + "type": "object", + "properties": { + "completed": { + "type": "integer" + } + } + }, "v1.Build": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index 2f16660c..509d2cc8 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -1,5 +1,16 @@ basePath: /api definitions: + currencies.Currency: + properties: + code: + type: string + local: + type: string + name: + type: string + symbol: + type: string + type: object repo.DocumentOut: properties: id: @@ -116,10 +127,6 @@ definitions: items: $ref: '#/definitions/repo.ItemAttachment' type: array - children: - items: - $ref: '#/definitions/repo.ItemSummary' - type: array createdAt: type: string description: @@ -238,7 +245,6 @@ definitions: archived: type: boolean assetId: - example: "0" type: string description: type: string @@ -608,12 +614,7 @@ definitions: token: type: string type: object - v1.ActionAmountResult: - properties: - completed: - type: integer - type: object - v1.ApiSummary: + v1.APISummary: properties: allowRegistration: type: boolean @@ -632,6 +633,11 @@ definitions: type: string type: array type: object + v1.ActionAmountResult: + properties: + completed: + type: integer + type: object v1.Build: properties: buildTime: @@ -789,6 +795,18 @@ paths: summary: Get Item by Asset ID tags: - Items + /v1/currency: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/currencies.Currency' + summary: Currency + tags: + - Base /v1/groups: get: produces: @@ -942,6 +960,13 @@ paths: type: string name: locations type: array + - collectionFormat: multi + description: parent Ids + in: query + items: + type: string + name: parentIds + type: array produces: - application/json responses: @@ -1656,7 +1681,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v1.ApiSummary' + $ref: '#/definitions/v1.APISummary' summary: Application Info tags: - Base @@ -1699,6 +1724,10 @@ paths: required: true schema: $ref: '#/definitions/v1.LoginForm' + - description: auth provider + in: query + name: provider + type: string produces: - application/json responses: diff --git a/backend/go.sum b/backend/go.sum index fc7cdd26..4cb487e6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -115,6 +115,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= @@ -122,6 +124,8 @@ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTS github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olahol/melody v1.1.4 h1:RQHfKZkQmDxI0+SLZRNBCn4LiXdqxLKRGSkT8Dyoe/E= github.com/olahol/melody v1.1.4/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= @@ -139,6 +143,10 @@ github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/backend/internal/core/currencies/currencies.go b/backend/internal/core/currencies/currencies.go new file mode 100644 index 00000000..b6a12c05 --- /dev/null +++ b/backend/internal/core/currencies/currencies.go @@ -0,0 +1,99 @@ +// Package currencies provides a shared definition of currencies. This uses a global +// variable to hold the currencies. +package currencies + +import ( + "bytes" + _ "embed" + "encoding/json" + "io" + "slices" + "strings" + "sync" +) + +//go:embed currencies.json +var defaults []byte + +type CollectorFunc func() ([]Currency, error) + +func CollectJSON(reader io.Reader) CollectorFunc { + return func() ([]Currency, error) { + var currencies []Currency + err := json.NewDecoder(reader).Decode(¤cies) + if err != nil { + return nil, err + } + + return currencies, nil + } +} + +func CollectDefaults() CollectorFunc { + return CollectJSON(bytes.NewReader(defaults)) +} + +func CollectionCurrencies(collectors ...CollectorFunc) ([]Currency, error) { + out := make([]Currency, 0, len(collectors)) + for i := range collectors { + c, err := collectors[i]() + if err != nil { + return nil, err + } + + out = append(out, c...) + } + + return out, nil +} + +type Currency struct { + Name string `json:"name"` + Code string `json:"code"` + Local string `json:"local"` + Symbol string `json:"symbol"` +} + +type CurrencyRegistry struct { + mu sync.RWMutex + registry map[string]Currency +} + +func NewCurrencyService(currencies []Currency) *CurrencyRegistry { + registry := make(map[string]Currency, len(currencies)) + for i := range currencies { + registry[currencies[i].Code] = currencies[i] + } + + return &CurrencyRegistry{ + registry: registry, + } +} + +func (cs *CurrencyRegistry) Slice() []Currency { + cs.mu.RLock() + defer cs.mu.RUnlock() + + keys := make([]string, 0, len(cs.registry)) + for key := range cs.registry { + keys = append(keys, key) + } + + slices.Sort(keys) + + out := make([]Currency, 0, len(cs.registry)) + for i := range keys { + out = append(out, cs.registry[keys[i]]) + } + + return out +} + +func (cs *CurrencyRegistry) IsSupported(code string) bool { + upper := strings.ToUpper(code) + + cs.mu.RLock() + defer cs.mu.RUnlock() + _, ok := cs.registry[upper] + return ok +} diff --git a/backend/internal/core/currencies/currencies.json b/backend/internal/core/currencies/currencies.json new file mode 100644 index 00000000..ffd39b48 --- /dev/null +++ b/backend/internal/core/currencies/currencies.json @@ -0,0 +1,626 @@ +[ + { + "code": "AED", + "local": "United Arab Emirates", + "symbol": "د.إ", + "name": "United Arab Emirates Dirham" + }, + { + "code": "AFN", + "local": "Afghanistan", + "symbol": "؋", + "name": "Afghan Afghani" + }, + { + "code": "ALL", + "local": "Albania", + "symbol": "L", + "name": "Albanian Lek" + }, + { + "code": "AMD", + "local": "Armenia", + "symbol": "֏", + "name": "Armenian Dram" + }, + { + "code": "ANG", + "local": "Netherlands Antilles", + "symbol": "ƒ", + "name": "Netherlands Antillean Guilder" + }, + { + "code": "AOA", + "local": "Angola", + "symbol": "Kz", + "name": "Angolan Kwanza" + }, + { + "code": "ARS", + "local": "Argentina", + "symbol": "$", + "name": "Argentine Peso" + }, + { + "code": "AUD", + "local": "Australia", + "symbol": "A$", + "name": "Australian Dollar" + }, + { + "code": "AWG", + "local": "Aruba", + "symbol": "ƒ", + "name": "Aruban Florin" + }, + { + "code": "AZN", + "local": "Azerbaijan", + "symbol": "₼", + "name": "Azerbaijani Manat" + }, + { + "code": "BAM", + "local": "Bosnia and Herzegovina", + "symbol": "KM", + "name": "Bosnia and Herzegovina Convertible Mark" + }, + { + "code": "BBD", + "local": "Barbados", + "symbol": "Bds$", + "name": "Barbadian Dollar" + }, + { + "code": "BDT", + "local": "Bangladesh", + "symbol": "৳", + "name": "Bangladeshi Taka" + }, + { + "code": "BGN", + "local": "Bulgaria", + "symbol": "лв", + "name": "Bulgarian lev" + }, + { + "code": "BHD", + "local": "Bahrain", + "symbol": "ب.د", + "name": "Bahraini Dinar" + }, + { + "code": "BIF", + "local": "Burundi", + "symbol": "FBu", + "name": "Burundian Franc" + }, + { + "code": "BMD", + "local": "Bermuda", + "symbol": "BD$", + "name": "Bermudian Dollar" + }, + { + "code": "BND", + "local": "Brunei", + "symbol": "B$", + "name": "Brunei Dollar" + }, + { + "code": "BOB", + "local": "Bolivia", + "symbol": "Bs.", + "name": "Bolivian Boliviano" + }, + { + "code": "BRL", + "local": "Brazil", + "symbol": "R$", + "name": "Brazilian Real" + }, + { + "code": "BSD", + "local": "Bahamas", + "symbol": "B$", + "name": "Bahamian Dollar" + }, + { + "code": "BTN", + "local": "Bhutan", + "symbol": "Nu.", + "name": "Bhutanese Ngultrum" + }, + { + "code": "BWP", + "local": "Botswana", + "symbol": "P", + "name": "Botswana Pula" + }, + { + "code": "BYN", + "local": "Belarus", + "symbol": "Br", + "name": "Belarusian Ruble" + }, + { + "code": "BZD", + "local": "Belize", + "symbol": "BZ$", + "name": "Belize Dollar" + }, + { + "code": "CAD", + "local": "Canada", + "symbol": "C$", + "name": "Canadian Dollar" + }, + { + "code": "CDF", + "local": "Democratic Republic of the Congo", + "symbol": "FC", + "name": "Congolese Franc" + }, + { + "code": "CHF", + "local": "Switzerland", + "symbol": "CHF", + "name": "Swiss Franc" + }, + { + "code": "CLP", + "local": "Chile", + "symbol": "CL$", + "name": "Chilean Peso" + }, + { + "code": "CNY", + "local": "China", + "symbol": "¥", + "name": "Chinese Yuan" + }, + { + "code": "COP", + "local": "Colombia", + "symbol": "COL$", + "name": "Colombian Peso" + }, + { + "code": "CRC", + "local": "Costa Rica", + "symbol": "₡", + "name": "Costa Rican Colón" + }, + { + "code": "CUP", + "local": "Cuba", + "symbol": "₱", + "name": "Cuban Peso" + }, + { + "code": "CVE", + "local": "Cape Verde", + "symbol": "$", + "name": "Cape Verdean Escudo" + }, + { + "code": "CZK", + "local": "Czech Republic", + "symbol": "Kč", + "name": "Czech Koruna" + }, + { + "code": "DJF", + "local": "Djibouti", + "symbol": "Fdj", + "name": "Djiboutian Franc" + }, + { + "code": "DKK", + "local": "Denmark", + "symbol": "kr", + "name": "Danish Krone" + }, + { + "code": "DOP", + "local": "Dominican Republic", + "symbol": "RD$", + "name": "Dominican Peso" + }, + { + "code": "DZD", + "local": "Algeria", + "symbol": "د.ج", + "name": "Algerian Dinar" + }, + { + "code": "EGP", + "local": "Egypt", + "symbol": "£", + "name": "Egyptian Pound" + }, + { + "code": "ERN", + "local": "Eritrea", + "symbol": "Nfk", + "name": "Eritrean Nakfa" + }, + { + "code": "ETB", + "local": "Ethiopia", + "symbol": "Br", + "name": "Ethiopian Birr" + }, + { + "code": "EUR", + "local": "Eurozone", + "symbol": "€", + "name": "Euro" + }, + { + "code": "FJD", + "local": "Fiji", + "symbol": "FJ$", + "name": "Fijian Dollar" + }, + { + "code": "FKP", + "local": "Falkland Islands", + "symbol": "£", + "name": "Falkland Islands Pound" + }, + { + "code": "FOK", + "local": "Faroe Islands", + "symbol": "kr", + "name": "Faroese Króna" + }, + { + "code": "GBP", + "local": "United Kingdom", + "symbol": "£", + "name": "British Pound Sterling" + }, + { + "code": "GEL", + "local": "Georgia", + "symbol": "₾", + "name": "Georgian Lari" + }, + { + "code": "GGP", + "local": "Guernsey", + "symbol": "£", + "name": "Guernsey Pound" + }, + { + "code": "GHS", + "local": "Ghana", + "symbol": "GH₵", + "name": "Ghanaian Cedi" + }, + { + "code": "GIP", + "local": "Gibraltar", + "symbol": "£", + "name": "Gibraltar Pound" + }, + { + "code": "GMD", + "local": "Gambia", + "symbol": "D", + "name": "Gambian Dalasi" + }, + { + "code": "GNF", + "local": "Guinea", + "symbol": "FG", + "name": "Guinean Franc" + }, + { + "code": "GTQ", + "local": "Guatemala", + "symbol": "Q", + "name": "Guatemalan Quetzal" + }, + { + "code": "GYD", + "local": "Guyana", + "symbol": "GY$", + "name": "Guyanese Dollar" + }, + { + "code": "HKD", + "local": "Hong Kong", + "symbol": "HK$", + "name": "Hong Kong Dollar" + }, + { + "code": "HNL", + "local": "Honduras", + "symbol": "L", + "name": "Honduran Lempira" + }, + { + "code": "HRK", + "local": "Croatia", + "symbol": "kn", + "name": "Croatian Kuna" + }, + { + "code": "HTG", + "local": "Haiti", + "symbol": "G", + "name": "Haitian Gourde" + }, + { + "code": "HUF", + "local": "Hungary", + "symbol": "Ft", + "name": "Hungarian Forint" + }, + { + "code": "IDR", + "local": "Indonesia", + "symbol": "Rp", + "name": "Indonesian Rupiah" + }, + { + "code": "ILS", + "local": "Israel", + "symbol": "₪", + "name": "Israeli New Shekel" + }, + { + "code": "IMP", + "local": "Isle of Man", + "symbol": "£", + "name": "Manx Pound" + }, + { + "code": "INR", + "local": "India", + "symbol": "₹", + "name": "Indian Rupee" + }, + { + "code": "IQD", + "local": "Iraq", + "symbol": "ع.د", + "name": "Iraqi Dinar" + }, + { + "code": "IRR", + "local": "Iran", + "symbol": "﷼", + "name": "Iranian Rial" + }, + { + "code": "ISK", + "local": "Iceland", + "symbol": "kr", + "name": "Icelandic Króna" + }, + { + "code": "JEP", + "local": "Jersey", + "symbol": "£", + "name": "Jersey Pound" + }, + { + "code": "JMD", + "local": "Jamaica", + "symbol": "J$", + "name": "Jamaican Dollar" + }, + { + "code": "JOD", + "local": "Jordan", + "symbol": "د.ا", + "name": "Jordanian Dinar" + }, + { + "code": "JPY", + "local": "Japan", + "symbol": "¥", + "name": "Japanese Yen" + }, + { + "code": "KES", + "local": "Kenya", + "symbol": "KSh", + "name": "Kenyan Shilling" + }, + { + "code": "KGS", + "local": "Kyrgyzstan", + "symbol": "с", + "name": "Kyrgyzstani Som" + }, + { + "code": "KHR", + "local": "Cambodia", + "symbol": "៛", + "name": "Cambodian Riel" + }, + { + "code": "KID", + "local": "Kiribati", + "symbol": "$", + "name": "Kiribati Dollar" + }, + { + "code": "KMF", + "local": "Comoros", + "symbol": "CF", + "name": "Comorian Franc" + }, + { + "code": "KRW", + "local": "South Korea", + "symbol": "₩", + "name": "South Korean Won" + }, + { + "code": "KWD", + "local": "Kuwait", + "symbol": "د.ك", + "name": "Kuwaiti Dinar" + }, + { + "code": "KYD", + "local": "Cayman Islands", + "symbol": "CI$", + "name": "Cayman Islands Dollar" + }, + { + "code": "KZT", + "local": "Kazakhstan", + "symbol": "₸", + "name": "Kazakhstani Tenge" + }, + { + "code": "LAK", + "local": "Laos", + "symbol": "₭", + "name": "Lao Kip" + }, + { + "code": "LBP", + "local": "Lebanon", + "symbol": "ل.ل", + "name": "Lebanese Pound" + }, + { + "code": "LKR", + "local": "Sri Lanka", + "symbol": "₨", + "name": "Sri Lankan Rupee" + }, + { + "code": "LRD", + "local": "Liberia", + "symbol": "L$", + "name": "Liberian Dollar" + }, + { + "code": "LSL", + "local": "Lesotho", + "symbol": "M", + "name": "Lesotho Loti" + }, + { + "code": "LYD", + "local": "Libya", + "symbol": "ل.د", + "name": "Libyan Dinar" + }, + { + "code": "MAD", + "local": "Morocco", + "symbol": "د.م.", + "name": "Moroccan Dirham" + }, + { + "code": "MDL", + "local": "Moldova", + "symbol": "lei", + "name": "Moldovan Leu" + }, + { + "code": "MGA", + "local": "Madagascar", + "symbol": "Ar", + "name": "Malagasy Ariary" + }, + { + "code": "MKD", + "local": "North Macedonia", + "symbol": "ден", + "name": "Macedonian Denar" + }, + { + "code": "MMK", + "local": "Myanmar", + "symbol": "K", + "name": "Myanmar Kyat" + }, + { + "code": "MNT", + "local": "Mongolia", + "symbol": "₮", + "name": "Mongolian Tugrik" + }, + { + "code": "MOP", + "local": "Macau", + "symbol": "MOP$", + "name": "Macanese Pataca" + }, + { + "code": "MRU", + "local": "Mauritania", + "symbol": "UM", + "name": "Mauritanian Ouguiya" + }, + { + "code": "MUR", + "local": "Mauritius", + "symbol": "₨", + "name": "Mauritian Rupee" + }, + { + "code": "MVR", + "local": "Maldives", + "symbol": "Rf", + "name": "Maldivian Rufiyaa" + }, + { + "code": "MWK", + "local": "Malawi", + "symbol": "MK", + "name": "Malawian Kwacha" + }, + { + "code": "MXN", + "local": "Mexico", + "symbol": "Mex$", + "name": "Mexican Peso" + }, + { + "code": "MYR", + "local": "Malaysia", + "symbol": "RM", + "name": "Malaysian Ringgit" + }, + { + "code": "MZN", + "local": "Mozambique", + "symbol": "MT", + "name": "Mozambican Metical" + }, + { + "code": "NAD", + "local": "Namibia", + "symbol": "N$", + "name": "Namibian Dollar" + }, + { + "code": "NGN", + "local": "Nigeria", + "symbol": "₦", + "name": "Nigerian Naira" + }, + { + "code": "NIO", + "local": "Nicaragua", + "symbol": "C$", + "name": "Nicaraguan Córdoba" + }, + { + "code": "UAH", + "local": "Ukraine", + "symbol": "₴", + "name": "Ukrainian Hryvnia" + } +] diff --git a/backend/internal/core/services/all.go b/backend/internal/core/services/all.go index 9b5e1277..3c03a4e9 100644 --- a/backend/internal/core/services/all.go +++ b/backend/internal/core/services/all.go @@ -2,6 +2,7 @@ package services import ( + "github.com/hay-kot/homebox/backend/internal/core/currencies" "github.com/hay-kot/homebox/backend/internal/data/repo" ) @@ -10,12 +11,14 @@ type AllServices struct { Group *GroupService Items *ItemService BackgroundService *BackgroundService + Currencies *currencies.CurrencyRegistry } type OptionsFunc func(*options) type options struct { autoIncrementAssetID bool + currencies []currencies.Currency } func WithAutoIncrementAssetID(v bool) func(*options) { @@ -24,13 +27,27 @@ func WithAutoIncrementAssetID(v bool) func(*options) { } } +func WithCurrencies(v []currencies.Currency) func(*options) { + return func(o *options) { + o.currencies = v + } +} + func New(repos *repo.AllRepos, opts ...OptionsFunc) *AllServices { if repos == nil { panic("repos cannot be nil") } + defaultCurrencies, err := currencies.CollectionCurrencies( + currencies.CollectDefaults(), + ) + if err != nil { + panic("failed to collect default currencies") + } + options := &options{ autoIncrementAssetID: true, + currencies: defaultCurrencies, } for _, opt := range opts { @@ -45,5 +62,6 @@ func New(repos *repo.AllRepos, opts ...OptionsFunc) *AllServices { autoIncrementAssetID: options.autoIncrementAssetID, }, BackgroundService: &BackgroundService{repos}, + Currencies: currencies.NewCurrencyService(options.currencies), } } diff --git a/backend/internal/core/services/main_test.go b/backend/internal/core/services/main_test.go index 57dce950..ecb07b0b 100644 --- a/backend/internal/core/services/main_test.go +++ b/backend/internal/core/services/main_test.go @@ -6,6 +6,7 @@ import ( "os" "testing" + "github.com/hay-kot/homebox/backend/internal/core/currencies" "github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus" "github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/hay-kot/homebox/backend/internal/data/repo" @@ -61,7 +62,12 @@ func TestMain(m *testing.M) { tClient = client tRepos = repo.New(tClient, tbus, os.TempDir()+"/homebox") - tSvc = New(tRepos) + + defaults, _ := currencies.CollectionCurrencies( + currencies.CollectDefaults(), + ) + + tSvc = New(tRepos, WithCurrencies(defaults)) defer func() { _ = client.Close() }() bootstrap() diff --git a/backend/internal/data/ent/group.go b/backend/internal/data/ent/group.go index 93570874..69c67de8 100644 --- a/backend/internal/data/ent/group.go +++ b/backend/internal/data/ent/group.go @@ -25,7 +25,7 @@ type Group struct { // Name holds the value of the "name" field. Name string `json:"name,omitempty"` // Currency holds the value of the "currency" field. - Currency group.Currency `json:"currency,omitempty"` + Currency string `json:"currency,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the GroupQuery when eager-loading is set. Edges GroupEdges `json:"edges"` @@ -170,7 +170,7 @@ func (gr *Group) assignValues(columns []string, values []any) error { if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field currency", values[i]) } else if value.Valid { - gr.Currency = group.Currency(value.String) + gr.Currency = value.String } default: gr.selectValues.Set(columns[i], values[i]) @@ -253,7 +253,7 @@ func (gr *Group) String() string { builder.WriteString(gr.Name) builder.WriteString(", ") builder.WriteString("currency=") - builder.WriteString(fmt.Sprintf("%v", gr.Currency)) + builder.WriteString(gr.Currency) builder.WriteByte(')') return builder.String() } diff --git a/backend/internal/data/ent/group/group.go b/backend/internal/data/ent/group/group.go index 47bceacd..32cb1012 100644 --- a/backend/internal/data/ent/group/group.go +++ b/backend/internal/data/ent/group/group.go @@ -3,7 +3,6 @@ package group import ( - "fmt" "time" "entgo.io/ent/dialect/sql" @@ -119,65 +118,12 @@ var ( UpdateDefaultUpdatedAt func() time.Time // NameValidator is a validator for the "name" field. It is called by the builders before save. NameValidator func(string) error + // DefaultCurrency holds the default value on creation for the "currency" field. + DefaultCurrency string // DefaultID holds the default value on creation for the "id" field. DefaultID func() uuid.UUID ) -// Currency defines the type for the "currency" enum field. -type Currency string - -// CurrencyUsd is the default value of the Currency enum. -const DefaultCurrency = CurrencyUsd - -// Currency values. -const ( - CurrencyAed Currency = "aed" - CurrencyAud Currency = "aud" - CurrencyBgn Currency = "bgn" - CurrencyBrl Currency = "brl" - CurrencyCad Currency = "cad" - CurrencyChf Currency = "chf" - CurrencyCzk Currency = "czk" - CurrencyDkk Currency = "dkk" - CurrencyEur Currency = "eur" - CurrencyGbp Currency = "gbp" - CurrencyHkd Currency = "hkd" - CurrencyIdr Currency = "idr" - CurrencyInr Currency = "inr" - CurrencyJpy Currency = "jpy" - CurrencyKrw Currency = "krw" - CurrencyMxn Currency = "mxn" - CurrencyNok Currency = "nok" - CurrencyNzd Currency = "nzd" - CurrencyPln Currency = "pln" - CurrencyRmb Currency = "rmb" - CurrencyRon Currency = "ron" - CurrencyRub Currency = "rub" - CurrencySar Currency = "sar" - CurrencySek Currency = "sek" - CurrencySgd Currency = "sgd" - CurrencyThb Currency = "thb" - CurrencyTry Currency = "try" - CurrencyUsd Currency = "usd" - CurrencyXag Currency = "xag" - CurrencyXau Currency = "xau" - CurrencyZar Currency = "zar" -) - -func (c Currency) String() string { - return string(c) -} - -// CurrencyValidator is a validator for the "currency" field enum values. It is called by the builders before save. -func CurrencyValidator(c Currency) error { - switch c { - case CurrencyAed, CurrencyAud, CurrencyBgn, CurrencyBrl, CurrencyCad, CurrencyChf, CurrencyCzk, CurrencyDkk, CurrencyEur, CurrencyGbp, CurrencyHkd, CurrencyIdr, CurrencyInr, CurrencyJpy, CurrencyKrw, CurrencyMxn, CurrencyNok, CurrencyNzd, CurrencyPln, CurrencyRmb, CurrencyRon, CurrencyRub, CurrencySar, CurrencySek, CurrencySgd, CurrencyThb, CurrencyTry, CurrencyUsd, CurrencyXag, CurrencyXau, CurrencyZar: - return nil - default: - return fmt.Errorf("group: invalid enum value for currency field: %q", c) - } -} - // OrderOption defines the ordering options for the Group queries. type OrderOption func(*sql.Selector) diff --git a/backend/internal/data/ent/group/where.go b/backend/internal/data/ent/group/where.go index 5f1bc06a..d18faa75 100644 --- a/backend/internal/data/ent/group/where.go +++ b/backend/internal/data/ent/group/where.go @@ -71,6 +71,11 @@ func Name(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldName, v)) } +// Currency applies equality check predicate on the "currency" field. It's identical to CurrencyEQ. +func Currency(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldCurrency, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.Group { return predicate.Group(sql.FieldEQ(FieldCreatedAt, v)) @@ -217,25 +222,70 @@ func NameContainsFold(v string) predicate.Group { } // CurrencyEQ applies the EQ predicate on the "currency" field. -func CurrencyEQ(v Currency) predicate.Group { +func CurrencyEQ(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldCurrency, v)) } // CurrencyNEQ applies the NEQ predicate on the "currency" field. -func CurrencyNEQ(v Currency) predicate.Group { +func CurrencyNEQ(v string) predicate.Group { return predicate.Group(sql.FieldNEQ(FieldCurrency, v)) } // CurrencyIn applies the In predicate on the "currency" field. -func CurrencyIn(vs ...Currency) predicate.Group { +func CurrencyIn(vs ...string) predicate.Group { return predicate.Group(sql.FieldIn(FieldCurrency, vs...)) } // CurrencyNotIn applies the NotIn predicate on the "currency" field. -func CurrencyNotIn(vs ...Currency) predicate.Group { +func CurrencyNotIn(vs ...string) predicate.Group { return predicate.Group(sql.FieldNotIn(FieldCurrency, vs...)) } +// CurrencyGT applies the GT predicate on the "currency" field. +func CurrencyGT(v string) predicate.Group { + return predicate.Group(sql.FieldGT(FieldCurrency, v)) +} + +// CurrencyGTE applies the GTE predicate on the "currency" field. +func CurrencyGTE(v string) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldCurrency, v)) +} + +// CurrencyLT applies the LT predicate on the "currency" field. +func CurrencyLT(v string) predicate.Group { + return predicate.Group(sql.FieldLT(FieldCurrency, v)) +} + +// CurrencyLTE applies the LTE predicate on the "currency" field. +func CurrencyLTE(v string) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldCurrency, v)) +} + +// CurrencyContains applies the Contains predicate on the "currency" field. +func CurrencyContains(v string) predicate.Group { + return predicate.Group(sql.FieldContains(FieldCurrency, v)) +} + +// CurrencyHasPrefix applies the HasPrefix predicate on the "currency" field. +func CurrencyHasPrefix(v string) predicate.Group { + return predicate.Group(sql.FieldHasPrefix(FieldCurrency, v)) +} + +// CurrencyHasSuffix applies the HasSuffix predicate on the "currency" field. +func CurrencyHasSuffix(v string) predicate.Group { + return predicate.Group(sql.FieldHasSuffix(FieldCurrency, v)) +} + +// CurrencyEqualFold applies the EqualFold predicate on the "currency" field. +func CurrencyEqualFold(v string) predicate.Group { + return predicate.Group(sql.FieldEqualFold(FieldCurrency, v)) +} + +// CurrencyContainsFold applies the ContainsFold predicate on the "currency" field. +func CurrencyContainsFold(v string) predicate.Group { + return predicate.Group(sql.FieldContainsFold(FieldCurrency, v)) +} + // HasUsers applies the HasEdge predicate on the "users" edge. func HasUsers() predicate.Group { return predicate.Group(func(s *sql.Selector) { diff --git a/backend/internal/data/ent/group_create.go b/backend/internal/data/ent/group_create.go index 8ccd05b4..be56ba01 100644 --- a/backend/internal/data/ent/group_create.go +++ b/backend/internal/data/ent/group_create.go @@ -63,15 +63,15 @@ func (gc *GroupCreate) SetName(s string) *GroupCreate { } // SetCurrency sets the "currency" field. -func (gc *GroupCreate) SetCurrency(gr group.Currency) *GroupCreate { - gc.mutation.SetCurrency(gr) +func (gc *GroupCreate) SetCurrency(s string) *GroupCreate { + gc.mutation.SetCurrency(s) return gc } // SetNillableCurrency sets the "currency" field if the given value is not nil. -func (gc *GroupCreate) SetNillableCurrency(gr *group.Currency) *GroupCreate { - if gr != nil { - gc.SetCurrency(*gr) +func (gc *GroupCreate) SetNillableCurrency(s *string) *GroupCreate { + if s != nil { + gc.SetCurrency(*s) } return gc } @@ -267,11 +267,6 @@ func (gc *GroupCreate) check() error { if _, ok := gc.mutation.Currency(); !ok { return &ValidationError{Name: "currency", err: errors.New(`ent: missing required field "Group.currency"`)} } - if v, ok := gc.mutation.Currency(); ok { - if err := group.CurrencyValidator(v); err != nil { - return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "Group.currency": %w`, err)} - } - } return nil } @@ -320,7 +315,7 @@ func (gc *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _node.Name = value } if value, ok := gc.mutation.Currency(); ok { - _spec.SetField(group.FieldCurrency, field.TypeEnum, value) + _spec.SetField(group.FieldCurrency, field.TypeString, value) _node.Currency = value } if nodes := gc.mutation.UsersIDs(); len(nodes) > 0 { diff --git a/backend/internal/data/ent/group_update.go b/backend/internal/data/ent/group_update.go index 872ee081..fdb11a39 100644 --- a/backend/internal/data/ent/group_update.go +++ b/backend/internal/data/ent/group_update.go @@ -57,15 +57,15 @@ func (gu *GroupUpdate) SetNillableName(s *string) *GroupUpdate { } // SetCurrency sets the "currency" field. -func (gu *GroupUpdate) SetCurrency(gr group.Currency) *GroupUpdate { - gu.mutation.SetCurrency(gr) +func (gu *GroupUpdate) SetCurrency(s string) *GroupUpdate { + gu.mutation.SetCurrency(s) return gu } // SetNillableCurrency sets the "currency" field if the given value is not nil. -func (gu *GroupUpdate) SetNillableCurrency(gr *group.Currency) *GroupUpdate { - if gr != nil { - gu.SetCurrency(*gr) +func (gu *GroupUpdate) SetNillableCurrency(s *string) *GroupUpdate { + if s != nil { + gu.SetCurrency(*s) } return gu } @@ -370,11 +370,6 @@ func (gu *GroupUpdate) check() error { return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)} } } - if v, ok := gu.mutation.Currency(); ok { - if err := group.CurrencyValidator(v); err != nil { - return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "Group.currency": %w`, err)} - } - } return nil } @@ -397,7 +392,7 @@ func (gu *GroupUpdate) sqlSave(ctx context.Context) (n int, err error) { _spec.SetField(group.FieldName, field.TypeString, value) } if value, ok := gu.mutation.Currency(); ok { - _spec.SetField(group.FieldCurrency, field.TypeEnum, value) + _spec.SetField(group.FieldCurrency, field.TypeString, value) } if gu.mutation.UsersCleared() { edge := &sqlgraph.EdgeSpec{ @@ -755,15 +750,15 @@ func (guo *GroupUpdateOne) SetNillableName(s *string) *GroupUpdateOne { } // SetCurrency sets the "currency" field. -func (guo *GroupUpdateOne) SetCurrency(gr group.Currency) *GroupUpdateOne { - guo.mutation.SetCurrency(gr) +func (guo *GroupUpdateOne) SetCurrency(s string) *GroupUpdateOne { + guo.mutation.SetCurrency(s) return guo } // SetNillableCurrency sets the "currency" field if the given value is not nil. -func (guo *GroupUpdateOne) SetNillableCurrency(gr *group.Currency) *GroupUpdateOne { - if gr != nil { - guo.SetCurrency(*gr) +func (guo *GroupUpdateOne) SetNillableCurrency(s *string) *GroupUpdateOne { + if s != nil { + guo.SetCurrency(*s) } return guo } @@ -1081,11 +1076,6 @@ func (guo *GroupUpdateOne) check() error { return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)} } } - if v, ok := guo.mutation.Currency(); ok { - if err := group.CurrencyValidator(v); err != nil { - return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "Group.currency": %w`, err)} - } - } return nil } @@ -1125,7 +1115,7 @@ func (guo *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error _spec.SetField(group.FieldName, field.TypeString, value) } if value, ok := guo.mutation.Currency(); ok { - _spec.SetField(group.FieldCurrency, field.TypeEnum, value) + _spec.SetField(group.FieldCurrency, field.TypeString, value) } if guo.mutation.UsersCleared() { edge := &sqlgraph.EdgeSpec{ diff --git a/backend/internal/data/ent/migrate/schema.go b/backend/internal/data/ent/migrate/schema.go index 265e2ea5..2b588380 100644 --- a/backend/internal/data/ent/migrate/schema.go +++ b/backend/internal/data/ent/migrate/schema.go @@ -117,7 +117,7 @@ var ( {Name: "created_at", Type: field.TypeTime}, {Name: "updated_at", Type: field.TypeTime}, {Name: "name", Type: field.TypeString, Size: 255}, - {Name: "currency", Type: field.TypeEnum, Enums: []string{"aed", "aud", "bgn", "brl", "cad", "chf", "czk", "dkk", "eur", "gbp", "hkd", "idr", "inr", "jpy", "krw", "mxn", "nok", "nzd", "pln", "rmb", "ron", "rub", "sar", "sek", "sgd", "thb", "try", "usd", "xag", "xau", "zar"}, Default: "usd"}, + {Name: "currency", Type: field.TypeString, Default: "usd"}, } // GroupsTable holds the schema information for the "groups" table. GroupsTable = &schema.Table{ diff --git a/backend/internal/data/ent/mutation.go b/backend/internal/data/ent/mutation.go index 5873a3cf..6fa15d3b 100644 --- a/backend/internal/data/ent/mutation.go +++ b/backend/internal/data/ent/mutation.go @@ -2340,7 +2340,7 @@ type GroupMutation struct { created_at *time.Time updated_at *time.Time name *string - currency *group.Currency + currency *string clearedFields map[string]struct{} users map[uuid.UUID]struct{} removedusers map[uuid.UUID]struct{} @@ -2581,12 +2581,12 @@ func (m *GroupMutation) ResetName() { } // SetCurrency sets the "currency" field. -func (m *GroupMutation) SetCurrency(gr group.Currency) { - m.currency = &gr +func (m *GroupMutation) SetCurrency(s string) { + m.currency = &s } // Currency returns the value of the "currency" field in the mutation. -func (m *GroupMutation) Currency() (r group.Currency, exists bool) { +func (m *GroupMutation) Currency() (r string, exists bool) { v := m.currency if v == nil { return @@ -2597,7 +2597,7 @@ func (m *GroupMutation) Currency() (r group.Currency, exists bool) { // OldCurrency returns the old "currency" field's value of the Group entity. // If the Group object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *GroupMutation) OldCurrency(ctx context.Context) (v group.Currency, err error) { +func (m *GroupMutation) OldCurrency(ctx context.Context) (v string, err error) { if !m.op.Is(OpUpdateOne) { return v, errors.New("OldCurrency is only allowed on UpdateOne operations") } @@ -3105,7 +3105,7 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { m.SetName(v) return nil case group.FieldCurrency: - v, ok := value.(group.Currency) + v, ok := value.(string) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } diff --git a/backend/internal/data/ent/runtime.go b/backend/internal/data/ent/runtime.go index d32ae7d4..c3aff001 100644 --- a/backend/internal/data/ent/runtime.go +++ b/backend/internal/data/ent/runtime.go @@ -161,6 +161,10 @@ func init() { return nil } }() + // groupDescCurrency is the schema descriptor for currency field. + groupDescCurrency := groupFields[1].Descriptor() + // group.DefaultCurrency holds the default value on creation for the currency field. + group.DefaultCurrency = groupDescCurrency.Default.(string) // groupDescID is the schema descriptor for id field. groupDescID := groupMixinFields0[0].Descriptor() // group.DefaultID holds the default value on creation for the id field. diff --git a/backend/internal/data/ent/schema/group.go b/backend/internal/data/ent/schema/group.go index 64e9d954..352ac0b2 100644 --- a/backend/internal/data/ent/schema/group.go +++ b/backend/internal/data/ent/schema/group.go @@ -27,41 +27,8 @@ func (Group) Fields() []ent.Field { field.String("name"). MaxLen(255). NotEmpty(), - field.Enum("currency"). - Default("usd"). - Values( - "aed", - "aud", - "bgn", - "brl", - "cad", - "chf", - "czk", - "dkk", - "eur", - "gbp", - "hkd", - "idr", - "inr", - "jpy", - "krw", - "mxn", - "nok", - "nzd", - "pln", - "rmb", - "ron", - "rub", - "sar", - "sek", - "sgd", - "thb", - "try", - "usd", - "xag", - "xau", - "zar", - ), + field.String("currency"). + Default("usd"), } } diff --git a/backend/internal/data/ent/schema/user.go b/backend/internal/data/ent/schema/user.go index 39eb38cf..10b0a8a9 100644 --- a/backend/internal/data/ent/schema/user.go +++ b/backend/internal/data/ent/schema/user.go @@ -78,7 +78,6 @@ func (g UserMixin) Fields() []ent.Field { } return nil - } func (g UserMixin) Edges() []ent.Edge { diff --git a/backend/internal/data/repo/repo_group.go b/backend/internal/data/repo/repo_group.go index 3e5e584f..8f93c780 100644 --- a/backend/internal/data/repo/repo_group.go +++ b/backend/internal/data/repo/repo_group.go @@ -28,7 +28,7 @@ func NewGroupRepository(db *ent.Client) *GroupRepository { Name: g.Name, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, - Currency: strings.ToUpper(g.Currency.String()), + Currency: strings.ToUpper(g.Currency), } } @@ -265,11 +265,9 @@ func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group, } func (r *GroupRepository) GroupUpdate(ctx context.Context, ID uuid.UUID, data GroupUpdate) (Group, error) { - currency := group.Currency(strings.ToLower(data.Currency)) - entity, err := r.db.Group.UpdateOneID(ID). SetName(data.Name). - SetCurrency(currency). + SetCurrency(strings.ToLower(data.Currency)). Save(ctx) return r.groupMapper.MapErr(entity, err) diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index bd487c0c..a454c1b1 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -68,7 +68,7 @@ type ( ItemUpdate struct { ParentID uuid.UUID `json:"parentId" extensions:"x-nullable,x-omitempty"` ID uuid.UUID `json:"id"` - AssetID AssetID `json:"assetId"` + AssetID AssetID `json:"assetId" swaggertype:"string"` Name string `json:"name"` Description string `json:"description"` Quantity int `json:"quantity"` diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index e2d04d44..efc18713 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -17,19 +17,20 @@ const ( type Config struct { conf.Version - Mode string `yaml:"mode" conf:"default:development"` // development or production - Web WebConfig `yaml:"web"` - Storage Storage `yaml:"storage"` - Log LoggerConf `yaml:"logger"` - Mailer MailerConf `yaml:"mailer"` - Demo bool `yaml:"demo"` - Debug DebugConf `yaml:"debug"` - Options Options `yaml:"options"` + Mode string `yaml:"mode" conf:"default:development"` // development or production + Web WebConfig `yaml:"web"` + Storage Storage `yaml:"storage"` + Log LoggerConf `yaml:"logger"` + Mailer MailerConf `yaml:"mailer"` + Demo bool `yaml:"demo"` + Debug DebugConf `yaml:"debug"` + Options Options `yaml:"options"` } type Options struct { AllowRegistration bool `yaml:"disable_registration" conf:"default:true"` AutoIncrementAssetID bool `yaml:"auto_increment_asset_id" conf:"default:true"` + CurrencyConfig string `yaml:"currencies"` } type DebugConf struct { diff --git a/backend/internal/sys/validate/errors.go b/backend/internal/sys/validate/errors.go index 2338785d..09fdf2cc 100644 --- a/backend/internal/sys/validate/errors.go +++ b/backend/internal/sys/validate/errors.go @@ -88,7 +88,7 @@ func (fe FieldErrors) Nil() bool { return len(fe) == 0 } -// Error implments the error interface. +// Error implements the error interface. func (fe FieldErrors) Error() string { d, err := json.Marshal(fe) if err != nil { @@ -101,6 +101,10 @@ func NewFieldErrors(errs ...FieldError) FieldErrors { return errs } +func NewFieldError(field, reason string) FieldError { + return FieldError{Field: field, Error: reason} +} + func IsFieldError(err error) bool { v := FieldErrors{} return errors.As(err, &v) diff --git a/docs/docs/api/openapi-2.0.json b/docs/docs/api/openapi-2.0.json index e2d98fec..2ff12952 100644 --- a/docs/docs/api/openapi-2.0.json +++ b/docs/docs/api/openapi-2.0.json @@ -143,6 +143,25 @@ } } }, + "/v1/currency": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Base" + ], + "summary": "Currency", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/currencies.Currency" + } + } + } + } + }, "/v1/groups": { "get": { "security": [ @@ -403,6 +422,16 @@ "description": "location Ids", "name": "locations", "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "parent Ids", + "name": "parentIds", + "in": "query" } ], "responses": { @@ -1567,7 +1596,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.ApiSummary" + "$ref": "#/definitions/v1.APISummary" } } } @@ -1638,6 +1667,12 @@ "schema": { "$ref": "#/definitions/v1.LoginForm" } + }, + { + "type": "string", + "description": "auth provider", + "name": "provider", + "in": "query" } ], "responses": { @@ -1816,6 +1851,23 @@ } }, "definitions": { + "currencies.Currency": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "local": { + "type": "string" + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, "repo.DocumentOut": { "type": "object", "properties": { @@ -1991,12 +2043,6 @@ "$ref": "#/definitions/repo.ItemAttachment" } }, - "children": { - "type": "array", - "items": { - "$ref": "#/definitions/repo.ItemSummary" - } - }, "createdAt": { "type": "string" }, @@ -2174,8 +2220,7 @@ "type": "boolean" }, "assetId": { - "type": "string", - "example": "0" + "type": "string" }, "description": { "type": "string" @@ -2729,15 +2774,7 @@ } } }, - "v1.ActionAmountResult": { - "type": "object", - "properties": { - "completed": { - "type": "integer" - } - } - }, - "v1.ApiSummary": { + "v1.APISummary": { "type": "object", "properties": { "allowRegistration": { @@ -2766,6 +2803,14 @@ } } }, + "v1.ActionAmountResult": { + "type": "object", + "properties": { + "completed": { + "type": "integer" + } + } + }, "v1.Build": { "type": "object", "properties": { diff --git a/docs/docs/quick-start.md b/docs/docs/quick-start.md index 8fa4e4d9..24439663 100644 --- a/docs/docs/quick-start.md +++ b/docs/docs/quick-start.md @@ -61,6 +61,7 @@ volumes: | HBOX_WEB_HOST | | host to run the web server on, if you're using docker do not change this | | HBOX_OPTIONS_ALLOW_REGISTRATION | true | allow users to register themselves | | HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID | true | auto increments the asset_id field for new items | +| HBOX_OPTIONS_CURRENCY_CONFIG | | json configuration file containing additional currencie | | HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB | | HBOX_WEB_READ_TIMEOUT | 10 | Read timeout of HTTP sever | | HBOX_WEB_WRITE_TIMEOUT | 10 | Write timeout of HTTP server | @@ -104,6 +105,7 @@ volumes: --debug-port/$HBOX_DEBUG_PORT (default: 4000) --options-allow-registration/$HBOX_OPTIONS_ALLOW_REGISTRATION (default: true) --options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID (default: true) + --options-currency-config/$HBOX_OPTIONS_CURRENCY_CONFIG --help/-h display this help message ``` diff --git a/docs/docs/tips-tricks.md b/docs/docs/tips-tricks.md index 2e0638fb..a5ed05a9 100644 --- a/docs/docs/tips-tricks.md +++ b/docs/docs/tips-tricks.md @@ -55,4 +55,26 @@ Homebox uses [shoutrrr](https://containrrr.dev/shoutrrr/0.7/) to send notificati **Notifications are sent on the day the maintenance is scheduled at or around 8am.** -As of `v0.9.0` we have limited support for complex scheduling of maintenance events. If you have requests for extended functionality, please open an issue on GitHub or reach out on Discord. We're still gauging the demand for this feature. \ No newline at end of file +As of `v0.9.0` we have limited support for complex scheduling of maintenance events. If you have requests for extended functionality, please open an issue on GitHub or reach out on Discord. We're still gauging the demand for this feature. + + +## Custom Currencies + +:octicons-tag-24: v0.11.0 + +Homebox allows you to add additional currencies to your instance by specify a JSON file containing the currencies you want to add. + +**Environment Variable:** `HBOX_OPTIONS_CURRENCY_CONFIG` + +### Example + +```json +[ + { + "code": "AED", + "local": "United Arab Emirates", + "symbol": "د.إ", + "name": "United Arab Emirates Dirham" + }, +] +``` diff --git a/frontend/lib/api/__test__/user/group.test.ts b/frontend/lib/api/__test__/user/group.test.ts index 40eafce1..4ad82b21 100644 --- a/frontend/lib/api/__test__/user/group.test.ts +++ b/frontend/lib/api/__test__/user/group.test.ts @@ -2,7 +2,6 @@ import { faker } from "@faker-js/faker"; import { describe, test, expect } from "vitest"; import { factories } from "../factories"; import { sharedUserClient } from "../test-utils"; -import { currencies } from "~~/lib/data/currency"; describe("first time user workflow (register, login, join group)", () => { test("user should be able to update group", async () => { @@ -29,20 +28,6 @@ describe("first time user workflow (register, login, join group)", () => { expect(group.currency).toBe("USD"); }); - test("currencies should be in sync with backend", async () => { - const { client } = await factories.client.singleUse(); - - for (const currency of currencies) { - const { response, data: group } = await client.group.update({ - name: faker.person.firstName(), - currency: currency.code, - }); - - expect(response.status).toBe(200); - expect(group.currency).toBe(currency.code); - } - }); - test("user should be able to join create join token and have user signup", async () => { const api = factories.client.public(); diff --git a/frontend/lib/api/__test__/user/items.test.ts b/frontend/lib/api/__test__/user/items.test.ts index 698bd3f3..72b54b13 100644 --- a/frontend/lib/api/__test__/user/items.test.ts +++ b/frontend/lib/api/__test__/user/items.test.ts @@ -9,7 +9,7 @@ import { sharedUserClient } from "../test-utils"; describe("user should be able to create an item and add an attachment", () => { let increment = 0; /** - * useLocatio sets up a location resource for testing, and returns a function + * useLocation sets up a location resource for testing, and returns a function * that can be used to delete the location from the backend server. */ async function useLocation(api: UserClient): Promise<[LocationOut, () => Promise]> { diff --git a/frontend/lib/api/classes/group.ts b/frontend/lib/api/classes/group.ts index 7468f095..963157f1 100644 --- a/frontend/lib/api/classes/group.ts +++ b/frontend/lib/api/classes/group.ts @@ -1,5 +1,11 @@ import { BaseAPI, route } from "../base"; -import { Group, GroupInvitation, GroupInvitationCreate, GroupUpdate } from "../types/data-contracts"; +import { + CurrenciesCurrency, + Group, + GroupInvitation, + GroupInvitationCreate, + GroupUpdate, +} from "../types/data-contracts"; export class GroupApi extends BaseAPI { createInvitation(data: GroupInvitationCreate) { @@ -21,4 +27,10 @@ export class GroupApi extends BaseAPI { url: route("/groups"), }); } + + currencies() { + return this.http.get({ + url: route("/currencies"), + }); + } } diff --git a/frontend/lib/api/public.ts b/frontend/lib/api/public.ts index ae8735ad..187728d0 100644 --- a/frontend/lib/api/public.ts +++ b/frontend/lib/api/public.ts @@ -1,5 +1,5 @@ import { BaseAPI, route } from "./base"; -import { ApiSummary, LoginForm, TokenResponse, UserRegistration } from "./types/data-contracts"; +import { APISummary, LoginForm, TokenResponse, UserRegistration } from "./types/data-contracts"; export type StatusResult = { health: boolean; @@ -10,7 +10,7 @@ export type StatusResult = { export class PublicApi extends BaseAPI { public status() { - return this.http.get({ url: route("/status") }); + return this.http.get({ url: route("/status") }); } public login(username: string, password: string, stayLoggedIn = false) { diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index e5457bb9..67c4f9c0 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -10,6 +10,13 @@ * --------------------------------------------------------------- */ +export interface CurrenciesCurrency { + code: string; + local: string; + name: string; + symbol: string; +} + export interface DocumentOut { id: string; path: string; @@ -81,7 +88,6 @@ export interface ItemOut { /** @example "0" */ assetId: string; attachments: ItemAttachment[]; - children: ItemSummary[]; createdAt: Date | string; description: string; fields: ItemField[]; @@ -141,7 +147,6 @@ export interface ItemSummary { export interface ItemUpdate { archived: boolean; - /** @example "0" */ assetId: string; description: string; fields: ItemField[]; @@ -364,11 +369,7 @@ export interface UserRegistration { token: string; } -export interface ActionAmountResult { - completed: number; -} - -export interface ApiSummary { +export interface APISummary { allowRegistration: boolean; build: Build; demo: boolean; @@ -378,6 +379,10 @@ export interface ApiSummary { versions: string[]; } +export interface ActionAmountResult { + completed: number; +} + export interface Build { buildTime: string; commit: string; diff --git a/frontend/lib/data/currency.ts b/frontend/lib/data/currency.ts deleted file mode 100644 index baa9b101..00000000 --- a/frontend/lib/data/currency.ts +++ /dev/null @@ -1,73 +0,0 @@ -export type Codes = - | "AED" - | "AUD" - | "BGN" - | "BRL" - | "CAD" - | "CHF" - | "CZK" - | "DKK" - | "EUR" - | "GBP" - | "HKD" - | "IDR" - | "INR" - | "JPY" - | "KRW" - | "MXN" - | "NOK" - | "NZD" - | "PLN" - | "RMB" - | "RUB" - | "RON" - | "SAR" - | "SEK" - | "SGD" - | "THB" - | "TRY" - | "USD" - | "XAG" - | "XAU" - | "ZAR"; - -export type Currency = { - code: Codes; - local: string; - symbol: string; - name: string; -}; - -export const currencies: Currency[] = [ - { code: "AED", local: "United Arab Emirates", symbol: "د.إ", name: "United Arab Emirates Dirham" }, - { code: "AUD", local: "Australia", symbol: "A$", name: "Australian Dollar" }, - { code: "BGN", local: "bg-BG", symbol: "lv", name: "Bulgarian lev" }, - { code: "BRL", local: "Brazil", symbol: "R$", name: "Brazilian Real" }, - { code: "CAD", local: "Canada", symbol: "C$", name: "Canadian Dollar" }, - { code: "CHF", local: "Switzerland", symbol: "CHF", name: "Swiss Franc" }, - { code: "CZK", local: "cs-CZ", symbol: "Kč", name: "Czech Koruna" }, - { code: "DKK", local: "da-DK", symbol: "kr", name: "Danish Krone" }, - { code: "EUR", local: "Eurozone", symbol: "€", name: "Euro" }, - { code: "GBP", local: "United Kingdom", symbol: "£", name: "British Pound Sterling" }, - { code: "HKD", local: "Hong Kong", symbol: "HK$", name: "Hong Kong Dollar" }, - { code: "IDR", local: "Indonesia", symbol: "Rp", name: "Indonesian Rupiah" }, - { code: "INR", local: "India", symbol: "₹", name: "Indian Rupee" }, - { code: "JPY", local: "Japan", symbol: "¥", name: "Japanese Yen" }, - { code: "KRW", local: "South Korea", symbol: "₩", name: "South Korean Won" }, - { code: "MXN", local: "Mexico", symbol: "Mex$", name: "Mexican Peso" }, - { code: "NOK", local: "Norway", symbol: "kr", name: "Norwegian Krone" }, - { code: "NZD", local: "New Zealand", symbol: "NZ$", name: "New Zealand Dollar" }, - { code: "PLN", local: "Poland", symbol: "zł", name: "Polish Zloty" }, - { code: "RMB", local: "zh-CN", symbol: "¥", name: "Chinese Yuan" }, - { code: "RON", local: "ro-RO", symbol: "lei", name: "Romanian Leu" }, - { code: "RUB", local: "Russia", symbol: "₽", name: "Russian Ruble" }, - { code: "SAR", local: "Saudi Arabia", symbol: "﷼", name: "Saudi Riyal" }, - { code: "SEK", local: "Sweden", symbol: "kr", name: "Swedish Krona" }, - { code: "SGD", local: "Singapore", symbol: "S$", name: "Singapore Dollar" }, - { code: "THB", local: "Thailand", symbol: "฿", name: "Thai Baht" }, - { code: "TRY", local: "Turkey", symbol: "₺", name: "Turkish Lira" }, - { code: "USD", local: "United States", symbol: "$", name: "United States Dollar" }, - { code: "XAG", local: "Global", symbol: "XAG", name: "Silver Troy Ounce" }, - { code: "XAU", local: "Global", symbol: "XAU", name: "Gold Troy Ounce" }, - { code: "ZAR", local: "South Africa", symbol: "R", name: "South African Rand" }, -]; diff --git a/frontend/pages/profile.vue b/frontend/pages/profile.vue index bc3273a9..fffdf124 100644 --- a/frontend/pages/profile.vue +++ b/frontend/pages/profile.vue @@ -1,8 +1,7 @@