diff --git a/Taskfile.yml b/Taskfile.yml index 6766493..e885488 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -27,6 +27,7 @@ tasks: - "./scripts/process-types.py" generates: - "./frontend/lib/api/types/data-contracts.ts" + - "./backend/ent/schema" - "./backend/app/api/docs/swagger.json" - "./backend/app/api/docs/swagger.yaml" diff --git a/backend/app/api/docs/docs.go b/backend/app/api/docs/docs.go index 5663e3f..b77cd35 100644 --- a/backend/app/api/docs/docs.go +++ b/backend/app/api/docs/docs.go @@ -21,6 +21,63 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/v1/groups": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Group" + ], + "summary": "Get the current user's group", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/repo.Group" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Group" + ], + "summary": "Updates some fields of the current users group", + "parameters": [ + { + "description": "User Data", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/repo.GroupUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/repo.Group" + } + } + } + } + }, "/v1/groups/invitations": { "post": { "security": [ @@ -32,7 +89,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "User" + "Group" ], "summary": "Get the current user", "parameters": [ @@ -1116,6 +1173,37 @@ const docTemplate = `{ } } }, + "repo.Group": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "repo.GroupUpdate": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "repo.ItemAttachment": { "type": "object", "properties": { diff --git a/backend/app/api/docs/swagger.json b/backend/app/api/docs/swagger.json index d625365..913b6a9 100644 --- a/backend/app/api/docs/swagger.json +++ b/backend/app/api/docs/swagger.json @@ -13,6 +13,63 @@ }, "basePath": "/api", "paths": { + "/v1/groups": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Group" + ], + "summary": "Get the current user's group", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/repo.Group" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Group" + ], + "summary": "Updates some fields of the current users group", + "parameters": [ + { + "description": "User Data", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/repo.GroupUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/repo.Group" + } + } + } + } + }, "/v1/groups/invitations": { "post": { "security": [ @@ -24,7 +81,7 @@ "application/json" ], "tags": [ - "User" + "Group" ], "summary": "Get the current user", "parameters": [ @@ -1108,6 +1165,37 @@ } } }, + "repo.Group": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "repo.GroupUpdate": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "repo.ItemAttachment": { "type": "object", "properties": { diff --git a/backend/app/api/docs/swagger.yaml b/backend/app/api/docs/swagger.yaml index 8c69cec..aca9f01 100644 --- a/backend/app/api/docs/swagger.yaml +++ b/backend/app/api/docs/swagger.yaml @@ -9,6 +9,26 @@ definitions: title: type: string type: object + repo.Group: + properties: + createdAt: + type: string + currency: + type: string + id: + type: string + name: + type: string + updatedAt: + type: string + type: object + repo.GroupUpdate: + properties: + currency: + type: string + name: + type: string + type: object repo.ItemAttachment: properties: createdAt: @@ -415,6 +435,40 @@ info: title: Go API Templates version: "1.0" paths: + /v1/groups: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/repo.Group' + security: + - Bearer: [] + summary: Get the current user's group + tags: + - Group + put: + parameters: + - description: User Data + in: body + name: payload + required: true + schema: + $ref: '#/definitions/repo.GroupUpdate' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/repo.Group' + security: + - Bearer: [] + summary: Updates some fields of the current users group + tags: + - Group /v1/groups/invitations: post: parameters: @@ -435,7 +489,7 @@ paths: - Bearer: [] summary: Get the current user tags: - - User + - Group /v1/items: get: parameters: diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 09191a4..4340f20 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -110,7 +110,7 @@ func run(cfg *config.Config) error { app.db = c app.repos = repo.New(c, cfg.Storage.Data) - app.services = services.NewServices(app.repos) + app.services = services.New(app.repos) // ========================================================================= // Start Server @@ -138,6 +138,14 @@ func run(cfg *config.Config) error { Msg("failed to purge expired tokens") } }) + go app.startBgTask(time.Duration(24)*time.Hour, func() { + _, err := app.repos.Groups.InvitationPurge(context.Background()) + if err != nil { + log.Error(). + Err(err). + Msg("failed to purge expired invitations") + } + }) // TODO: Remove through external API that does setup if cfg.Demo { diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 9cfd289..813ec26 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -72,6 +72,10 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { r.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate()) + // TODO: I don't like /groups being the URL for users + r.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet()) + r.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate()) + r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll()) r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate()) r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet()) diff --git a/backend/app/api/v1/v1_ctrl_group.go b/backend/app/api/v1/v1_ctrl_group.go index 8b78fd8..e8bd796 100644 --- a/backend/app/api/v1/v1_ctrl_group.go +++ b/backend/app/api/v1/v1_ctrl_group.go @@ -2,8 +2,10 @@ package v1 import ( "net/http" + "strings" "time" + "github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/services" "github.com/hay-kot/homebox/backend/pkgs/server" "github.com/rs/zerolog/log" @@ -22,9 +24,63 @@ type ( } ) -// HandleUserSelf godoc +// HandleGroupGet godoc +// @Summary Get the current user's group +// @Tags Group +// @Produce json +// @Success 200 {object} repo.Group +// @Router /v1/groups [Get] +// @Security Bearer +func (ctrl *V1Controller) HandleGroupGet() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := services.NewContext(r.Context()) + + group, err := ctrl.svc.Group.Get(ctx) + if err != nil { + log.Err(err).Msg("failed to get group") + server.RespondError(w, http.StatusInternalServerError, err) + return + } + group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower caseÍ + + server.Respond(w, http.StatusOK, group) + + } +} + +// HandleGroupUpdate godoc +// @Summary Updates some fields of the current users group +// @Tags Group +// @Produce json +// @Param payload body repo.GroupUpdate true "User Data" +// @Success 200 {object} repo.Group +// @Router /v1/groups [Put] +// @Security Bearer +func (ctrl *V1Controller) HandleGroupUpdate() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + data := repo.GroupUpdate{} + + if err := server.Decode(r, &data); err != nil { + server.RespondError(w, http.StatusBadRequest, err) + return + } + + ctx := services.NewContext(r.Context()) + + group, err := ctrl.svc.Group.UpdateGroup(ctx, data) + if err != nil { + log.Err(err).Msg("failed to update group") + server.RespondError(w, http.StatusInternalServerError, err) + return + } + group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower case + server.Respond(w, http.StatusOK, group) + } +} + +// HandleGroupInvitationsCreate godoc // @Summary Get the current user -// @Tags User +// @Tags Group // @Produce json // @Param payload body GroupInvitationCreate true "User Data" // @Success 200 {object} GroupInvitation @@ -36,7 +92,7 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc { if err := server.Decode(r, &data); err != nil { log.Err(err).Msg("failed to decode user registration data") - server.RespondError(w, http.StatusInternalServerError, err) + server.RespondError(w, http.StatusBadRequest, err) return } @@ -46,7 +102,7 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc { ctx := services.NewContext(r.Context()) - token, err := ctrl.svc.User.NewInvitation(ctx, data.Uses, data.ExpiresAt) + token, err := ctrl.svc.Group.NewInvitation(ctx, data.Uses, data.ExpiresAt) if err != nil { log.Err(err).Msg("failed to create new token") server.RespondError(w, http.StatusInternalServerError, err) diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 7485a7e..f46cc94 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -121,6 +121,9 @@ const DefaultCurrency = CurrencyUsd // Currency values. const ( CurrencyUsd Currency = "usd" + CurrencyEur Currency = "eur" + CurrencyGbp Currency = "gbp" + CurrencyJpy Currency = "jpy" ) func (c Currency) String() string { @@ -130,7 +133,7 @@ func (c Currency) String() string { // 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 CurrencyUsd: + case CurrencyUsd, CurrencyEur, CurrencyGbp, CurrencyJpy: return nil default: return fmt.Errorf("group: invalid enum value for currency field: %q", c) diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index d849a68..7954764 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -127,7 +127,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{"usd"}, Default: "usd"}, + {Name: "currency", Type: field.TypeEnum, Enums: []string{"usd", "eur", "gbp", "jpy"}, Default: "usd"}, } // GroupsTable holds the schema information for the "groups" table. GroupsTable = &schema.Table{ diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 01f0c1f..a9d51ed 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -27,7 +27,7 @@ func (Group) Fields() []ent.Field { NotEmpty(), field.Enum("currency"). Default("usd"). - Values("usd"), // TODO: add more currencies + Values("usd", "eur", "gbp", "jpy"), // TODO: add more currencies } } diff --git a/backend/internal/repo/repo_group.go b/backend/internal/repo/repo_group.go index ef7b502..ac0e071 100644 --- a/backend/internal/repo/repo_group.go +++ b/backend/internal/repo/repo_group.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" "github.com/hay-kot/homebox/backend/ent" + "github.com/hay-kot/homebox/backend/ent/group" "github.com/hay-kot/homebox/backend/ent/groupinvitationtoken" ) @@ -15,11 +16,16 @@ type GroupRepository struct { type ( Group struct { - ID uuid.UUID - Name string - CreatedAt time.Time - UpdatedAt time.Time - Currency string + ID uuid.UUID `json:"id,omitempty"` + Name string `json:"name,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` + Currency string `json:"currency,omitempty"` + } + + GroupUpdate struct { + Name string `json:"name"` + Currency string `json:"currency"` } GroupInvitationCreate struct { @@ -69,6 +75,17 @@ func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group, Save(ctx)) } +func (r *GroupRepository) GroupUpdate(ctx context.Context, ID uuid.UUID, data GroupUpdate) (Group, error) { + currency := group.Currency(data.Currency) + + entity, err := r.db.Group.UpdateOneID(ID). + SetName(data.Name). + SetCurrency(currency). + Save(ctx) + + return mapToGroupErr(entity, err) +} + func (r *GroupRepository) GroupByID(ctx context.Context, id uuid.UUID) (Group, error) { return mapToGroupErr(r.db.Group.Get(ctx, id)) } diff --git a/backend/internal/repo/repo_group_test.go b/backend/internal/repo/repo_group_test.go index 198393d..5f3faaf 100644 --- a/backend/internal/repo/repo_group_test.go +++ b/backend/internal/repo/repo_group_test.go @@ -18,3 +18,16 @@ func Test_Group_Create(t *testing.T) { assert.NoError(t, err) assert.Equal(t, g.ID, foundGroup.ID) } + +func Test_Group_Update(t *testing.T) { + g, err := tRepos.Groups.GroupCreate(context.Background(), "test") + assert.NoError(t, err) + + g, err = tRepos.Groups.GroupUpdate(context.Background(), g.ID, GroupUpdate{ + Name: "test2", + Currency: "eur", + }) + assert.NoError(t, err) + assert.Equal(t, "test2", g.Name) + assert.Equal(t, "eur", g.Currency) +} diff --git a/backend/internal/services/all.go b/backend/internal/services/all.go index a8e2e8e..20e377f 100644 --- a/backend/internal/services/all.go +++ b/backend/internal/services/all.go @@ -4,18 +4,20 @@ import "github.com/hay-kot/homebox/backend/internal/repo" type AllServices struct { User *UserService + Group *GroupService Location *LocationService Labels *LabelService Items *ItemService } -func NewServices(repos *repo.AllRepos) *AllServices { +func New(repos *repo.AllRepos) *AllServices { if repos == nil { panic("repos cannot be nil") } return &AllServices{ User: &UserService{repos}, + Group: &GroupService{repos}, Location: &LocationService{repos}, Labels: &LabelService{repos}, Items: &ItemService{ diff --git a/backend/internal/services/main_test.go b/backend/internal/services/main_test.go index 26c3519..37cb556 100644 --- a/backend/internal/services/main_test.go +++ b/backend/internal/services/main_test.go @@ -63,7 +63,7 @@ func TestMain(m *testing.M) { tClient = client tRepos = repo.New(tClient, os.TempDir()+"/homebox") - tSvc = NewServices(tRepos) + tSvc = New(tRepos) defer client.Close() bootstrap() diff --git a/backend/internal/services/service_group.go b/backend/internal/services/service_group.go new file mode 100644 index 0000000..4859f85 --- /dev/null +++ b/backend/internal/services/service_group.go @@ -0,0 +1,47 @@ +package services + +import ( + "errors" + "strings" + "time" + + "github.com/hay-kot/homebox/backend/internal/repo" + "github.com/hay-kot/homebox/backend/pkgs/hasher" +) + +type GroupService struct { + repos *repo.AllRepos +} + +func (svc *GroupService) Get(ctx Context) (repo.Group, error) { + return svc.repos.Groups.GroupByID(ctx.Context, ctx.GID) +} + +func (svc *GroupService) UpdateGroup(ctx Context, data repo.GroupUpdate) (repo.Group, error) { + if data.Name == "" { + data.Name = ctx.User.GroupName + } + + if data.Currency == "" { + return repo.Group{}, errors.New("currency cannot be empty") + } + + data.Currency = strings.ToLower(data.Currency) + + return svc.repos.Groups.GroupUpdate(ctx.Context, ctx.GID, data) +} + +func (svc *GroupService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) { + token := hasher.GenerateToken() + + _, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{ + Token: token.Hash, + Uses: uses, + ExpiresAt: expiresAt, + }) + if err != nil { + return "", err + } + + return token.Raw, nil +} diff --git a/backend/internal/services/service_user.go b/backend/internal/services/service_user.go index b678ab9..3060ee7 100644 --- a/backend/internal/services/service_user.go +++ b/backend/internal/services/service_user.go @@ -186,21 +186,6 @@ func (svc *UserService) DeleteSelf(ctx context.Context, ID uuid.UUID) error { return svc.repos.Users.Delete(ctx, ID) } -func (svc *UserService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) { - token := hasher.GenerateToken() - - _, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{ - Token: token.Hash, - Uses: uses, - ExpiresAt: expiresAt, - }) - if err != nil { - return "", err - } - - return token.Raw, nil -} - func (svc *UserService) ChangePassword(ctx Context, current string, new string) (ok bool) { usr, err := svc.repos.Users.GetOneId(ctx, ctx.UID) if err != nil { diff --git a/frontend/components/Form/Select.vue b/frontend/components/Form/Select.vue index 72f1d2e..5c84066 100644 --- a/frontend/components/Form/Select.vue +++ b/frontend/components/Form/Select.vue @@ -25,7 +25,7 @@ }, modelValue: { // eslint-disable-next-line @typescript-eslint/no-explicit-any - type: [Object, String, Boolean] as any, + type: [Object, String] as any, default: null, }, items: { @@ -53,10 +53,19 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any function compare(a: any, b: any): boolean { - if (props.value != null) { + if (props.value) { return a[props.value] === b[props.value]; } - return a === b; + + if (a === b) { + return true; + } + + if (!a || !b) { + return false; + } + + return JSON.stringify(a) === JSON.stringify(b); } watch( diff --git a/frontend/components/global/Currency.vue b/frontend/components/global/Currency.vue new file mode 100644 index 0000000..9a2c561 --- /dev/null +++ b/frontend/components/global/Currency.vue @@ -0,0 +1,22 @@ + + {{ value }} + + + diff --git a/frontend/components/global/DetailsSection/DetailsSection.vue b/frontend/components/global/DetailsSection/DetailsSection.vue index f8ee495..6b083e3 100644 --- a/frontend/components/global/DetailsSection/DetailsSection.vue +++ b/frontend/components/global/DetailsSection/DetailsSection.vue @@ -7,9 +7,8 @@