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 bb76e69..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 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..d8ba486 100644 --- a/backend/app/api/v1/v1_ctrl_group.go +++ b/backend/app/api/v1/v1_ctrl_group.go @@ -4,6 +4,7 @@ import ( "net/http" "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 +23,61 @@ 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.Error().Err(err).Msg("failed to get group") + server.RespondError(w, http.StatusInternalServerError, err) + return + } + + 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 { + server.RespondError(w, http.StatusInternalServerError, err) + return + } + + 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 +89,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 +99,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/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..12e26c5 --- /dev/null +++ b/backend/internal/services/service_group.go @@ -0,0 +1,44 @@ +package services + +import ( + "errors" + "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") + } + + 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/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..15bff87 --- /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;