From 461be2afca6d7edbf5c3ece715f7364c5e7eb26b Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sat, 15 Oct 2022 12:15:55 -0800 Subject: [PATCH] feat: currency selection support (#72) * initial UI for currency selection * add task to purge invitation tokens * group API contracts * fix type import * use auth middleware * add currency setting support (UI) * use group settings for format currency * fix casing --- Taskfile.yml | 1 + backend/app/api/docs/docs.go | 90 +++++++- backend/app/api/docs/swagger.json | 90 +++++++- backend/app/api/docs/swagger.yaml | 56 ++++- backend/app/api/main.go | 10 +- backend/app/api/routes.go | 4 + backend/app/api/v1/v1_ctrl_group.go | 64 +++++- backend/ent/group/group.go | 5 +- backend/ent/migrate/schema.go | 2 +- backend/ent/schema/group.go | 2 +- backend/internal/repo/repo_group.go | 27 ++- backend/internal/repo/repo_group_test.go | 13 ++ backend/internal/services/all.go | 4 +- backend/internal/services/main_test.go | 2 +- backend/internal/services/service_group.go | 47 ++++ backend/internal/services/service_user.go | 15 -- frontend/components/Form/Select.vue | 15 +- frontend/components/global/Currency.vue | 22 ++ .../global/DetailsSection/DetailsSection.vue | 9 +- .../components/global/DetailsSection/types.ts | 22 +- frontend/composables/use-formatters.ts | 21 ++ frontend/composables/use-preferences.ts | 32 +-- frontend/composables/use-theme.ts | 2 +- frontend/layouts/default.vue | 64 +++++- frontend/layouts/home.vue | 69 ------ frontend/lib/api/__test__/public.test.ts | 40 ---- frontend/lib/api/__test__/user/group.test.ts | 66 ++++++ frontend/lib/api/classes/group.ts | 15 +- frontend/lib/api/types/data-contracts.ts | 13 ++ frontend/lib/data/currency.ts | 35 +++ frontend/lib/data/themes.ts | 150 +++++++++++++ frontend/middleware/auth.ts | 15 ++ frontend/pages/home.vue | 12 +- frontend/pages/item/[id]/edit.vue | 2 +- frontend/pages/item/[id]/index.vue | 16 +- frontend/pages/item/new.vue | 2 +- frontend/pages/items.vue | 3 +- frontend/pages/label/[id].vue | 6 +- frontend/pages/location/[id].vue | 6 +- frontend/pages/profile.vue | 204 +++++++----------- 40 files changed, 930 insertions(+), 343 deletions(-) create mode 100644 backend/internal/services/service_group.go create mode 100644 frontend/components/global/Currency.vue create mode 100644 frontend/composables/use-formatters.ts delete mode 100644 frontend/layouts/home.vue create mode 100644 frontend/lib/api/__test__/user/group.test.ts create mode 100644 frontend/lib/data/currency.ts create mode 100644 frontend/lib/data/themes.ts create mode 100644 frontend/middleware/auth.ts 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 @@ + + + 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 @@
- + + @@ -21,11 +20,11 @@ + + diff --git a/frontend/layouts/home.vue b/frontend/layouts/home.vue deleted file mode 100644 index 9f0adc7..0000000 --- a/frontend/layouts/home.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - diff --git a/frontend/lib/api/__test__/public.test.ts b/frontend/lib/api/__test__/public.test.ts index 044558e..09a1aa1 100644 --- a/frontend/lib/api/__test__/public.test.ts +++ b/frontend/lib/api/__test__/public.test.ts @@ -1,6 +1,5 @@ import { describe, test, expect } from "vitest"; import { factories } from "./factories"; -import { sharedUserClient } from "./test-utils"; describe("[GET] /api/v1/status", () => { test("server should respond", async () => { @@ -32,43 +31,4 @@ describe("first time user workflow (register, login, join group)", () => { expect(response.status).toBe(204); } }); - - test("user should be able to join create join token and have user signup", async () => { - // Setup User 1 Token - - const client = await sharedUserClient(); - const { data: user1 } = await client.user.self(); - - const { response, data } = await client.group.createInvitation({ - expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), - uses: 1, - }); - - expect(response.status).toBe(201); - expect(data.token).toBeTruthy(); - - // Create User 2 with token - - const duplicateUser = factories.user(); - duplicateUser.token = data.token; - - const { response: registerResp } = await api.register(duplicateUser); - expect(registerResp.status).toBe(204); - - const { response: loginResp, data: loginData } = await api.login(duplicateUser.email, duplicateUser.password); - expect(loginResp.status).toBe(200); - - // Get Self and Assert - - const client2 = factories.client.user(loginData.token); - - const { data: user2 } = await client2.user.self(); - - user2.item.groupName = user1.item.groupName; - - // Cleanup User 2 - - const { response: deleteResp } = await client2.user.delete(); - expect(deleteResp.status).toBe(204); - }); }); diff --git a/frontend/lib/api/__test__/user/group.test.ts b/frontend/lib/api/__test__/user/group.test.ts new file mode 100644 index 0000000..93aa576 --- /dev/null +++ b/frontend/lib/api/__test__/user/group.test.ts @@ -0,0 +1,66 @@ +import { faker } from "@faker-js/faker"; +import { describe, test, expect } from "vitest"; +import { factories } from "../factories"; +import { sharedUserClient } from "../test-utils"; + +describe("first time user workflow (register, login, join group)", () => { + test("user should be able to update group", async () => { + const { client } = await factories.client.singleUse(); + + const name = faker.name.firstName(); + + const { response, data: group } = await client.group.update({ + name, + currency: "eur", + }); + + expect(response.status).toBe(200); + expect(group.name).toBe(name); + }); + + test("user should be able to get own group", async () => { + const { client } = await factories.client.singleUse(); + + const { response, data: group } = await client.group.get(); + + expect(response.status).toBe(200); + expect(group.name).toBeTruthy(); + expect(group.currency).toBe("USD"); + }); + + test("user should be able to join create join token and have user signup", async () => { + const api = factories.client.public(); + + // Setup User 1 Token + const client = await sharedUserClient(); + const { data: user1 } = await client.user.self(); + + const { response, data } = await client.group.createInvitation({ + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + uses: 1, + }); + + expect(response.status).toBe(201); + expect(data.token).toBeTruthy(); + + // Create User 2 with token + const duplicateUser = factories.user(); + duplicateUser.token = data.token; + + const { response: registerResp } = await api.register(duplicateUser); + expect(registerResp.status).toBe(204); + + const { response: loginResp, data: loginData } = await api.login(duplicateUser.email, duplicateUser.password); + expect(loginResp.status).toBe(200); + + // Get Self and Assert + const client2 = factories.client.user(loginData.token); + const { data: user2 } = await client2.user.self(); + + user2.item.groupName = user1.item.groupName; + + // Cleanup User 2 + const { response: deleteResp } = await client2.user.delete(); + expect(deleteResp.status).toBe(204); + }); +}); diff --git a/frontend/lib/api/classes/group.ts b/frontend/lib/api/classes/group.ts index 5a687f1..7468f09 100644 --- a/frontend/lib/api/classes/group.ts +++ b/frontend/lib/api/classes/group.ts @@ -1,5 +1,5 @@ import { BaseAPI, route } from "../base"; -import { GroupInvitation, GroupInvitationCreate } from "../types/data-contracts"; +import { Group, GroupInvitation, GroupInvitationCreate, GroupUpdate } from "../types/data-contracts"; export class GroupApi extends BaseAPI { createInvitation(data: GroupInvitationCreate) { @@ -8,4 +8,17 @@ export class GroupApi extends BaseAPI { body: data, }); } + + update(data: GroupUpdate) { + return this.http.put({ + url: route("/groups"), + body: data, + }); + } + + get() { + return this.http.get({ + url: route("/groups"), + }); + } } diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 585feb1..360ae2a 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -16,6 +16,19 @@ export interface DocumentOut { title: string; } +export interface Group { + createdAt: Date; + currency: string; + id: string; + name: string; + updatedAt: Date; +} + +export interface GroupUpdate { + currency: string; + name: string; +} + export interface ItemAttachment { createdAt: Date; document: DocumentOut; diff --git a/frontend/lib/data/currency.ts b/frontend/lib/data/currency.ts new file mode 100644 index 0000000..8643d96 --- /dev/null +++ b/frontend/lib/data/currency.ts @@ -0,0 +1,35 @@ +export type Codes = "USD" | "EUR" | "GBP" | "JPY"; + +export type Currency = { + code: Codes; + local: string; + symbol: string; + name: string; +}; + +export const currencies: Currency[] = [ + { + code: "USD", + local: "en-US", + symbol: "$", + name: "US Dollar", + }, + { + code: "EUR", + local: "de-DE", + symbol: "€", + name: "Euro", + }, + { + code: "GBP", + local: "en-GB", + symbol: "£", + name: "British Pound", + }, + { + code: "JPY", + local: "ja-JP", + symbol: "¥", + name: "Japanese Yen", + }, +]; diff --git a/frontend/lib/data/themes.ts b/frontend/lib/data/themes.ts new file mode 100644 index 0000000..55f0f55 --- /dev/null +++ b/frontend/lib/data/themes.ts @@ -0,0 +1,150 @@ +export type DaisyTheme = + | "light" + | "dark" + | "cupcake" + | "bumblebee" + | "emerald" + | "corporate" + | "synthwave" + | "retro" + | "cyberpunk" + | "valentine" + | "halloween" + | "garden" + | "forest" + | "aqua" + | "lofi" + | "pastel" + | "fantasy" + | "wireframe" + | "black" + | "luxury" + | "dracula" + | "cmyk" + | "autumn" + | "business" + | "acid" + | "lemonade" + | "night" + | "coffee" + | "winter"; + +export type ThemeOption = { + label: string; + value: DaisyTheme; +}; + +export const themes: ThemeOption[] = [ + { + label: "Garden", + value: "garden", + }, + { + label: "Light", + value: "light", + }, + { + label: "Cupcake", + value: "cupcake", + }, + { + label: "Bumblebee", + value: "bumblebee", + }, + { + label: "Emerald", + value: "emerald", + }, + { + label: "Corporate", + value: "corporate", + }, + { + label: "Synthwave", + value: "synthwave", + }, + { + label: "Retro", + value: "retro", + }, + { + label: "Cyberpunk", + value: "cyberpunk", + }, + { + label: "Valentine", + value: "valentine", + }, + { + label: "Halloween", + value: "halloween", + }, + { + label: "Forest", + value: "forest", + }, + { + label: "Aqua", + value: "aqua", + }, + { + label: "Lofi", + value: "lofi", + }, + { + label: "Pastel", + value: "pastel", + }, + { + label: "Fantasy", + value: "fantasy", + }, + { + label: "Wireframe", + value: "wireframe", + }, + { + label: "Black", + value: "black", + }, + { + label: "Luxury", + value: "luxury", + }, + { + label: "Dracula", + value: "dracula", + }, + { + label: "Cmyk", + value: "cmyk", + }, + { + label: "Autumn", + value: "autumn", + }, + { + label: "Business", + value: "business", + }, + { + label: "Acid", + value: "acid", + }, + { + label: "Lemonade", + value: "lemonade", + }, + { + label: "Night", + value: "night", + }, + { + label: "Coffee", + value: "coffee", + }, + { + label: "Winter", + value: "winter", + }, +]; diff --git a/frontend/middleware/auth.ts b/frontend/middleware/auth.ts new file mode 100644 index 0000000..f67fa01 --- /dev/null +++ b/frontend/middleware/auth.ts @@ -0,0 +1,15 @@ +import { useAuthStore } from "~~/stores/auth"; + +export default defineNuxtRouteMiddleware(async () => { + const auth = useAuthStore(); + const api = useUserApi(); + + if (!auth.self) { + const { data, error } = await api.user.self(); + if (error) { + navigateTo("/"); + } + + auth.$patch({ self: data.item }); + } +}); diff --git a/frontend/pages/home.vue b/frontend/pages/home.vue index c3a4819..8bf0a54 100644 --- a/frontend/pages/home.vue +++ b/frontend/pages/home.vue @@ -5,8 +5,9 @@ import { useLocationStore } from "~~/stores/locations"; definePageMeta({ - layout: "home", + middleware: ["auth"], }); + useHead({ title: "Homebox | Home", }); @@ -15,15 +16,6 @@ const auth = useAuthStore(); - if (auth.self === null) { - const { data, error } = await api.user.self(); - if (error) { - navigateTo("/"); - } - - auth.$patch({ self: data.item }); - } - const itemsStore = useItemStore(); const items = computed(() => itemsStore.items); diff --git a/frontend/pages/item/[id]/edit.vue b/frontend/pages/item/[id]/edit.vue index 3a58e61..8ab71d4 100644 --- a/frontend/pages/item/[id]/edit.vue +++ b/frontend/pages/item/[id]/edit.vue @@ -6,7 +6,7 @@ import { capitalize } from "~~/lib/strings"; definePageMeta({ - layout: "home", + middleware: ["auth"], }); const route = useRoute(); diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index be42203..32145ab 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -1,9 +1,9 @@