diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0543f2852..0b40a82c5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,11 +4,11 @@ updates: directory: ./ schedule: interval: weekly - - package-ecosystem: yarn + - package-ecosystem: npm directory: ./frontend/sac-mobile schedule: interval: weekly - - package-ecosystem: yarn + - package-ecosystem: npm directory: ./frontend/sac-web schedule: interval: weekly diff --git a/backend/src/controllers/user.go b/backend/src/controllers/user.go index 1b509bf3d..86b9c6b23 100644 --- a/backend/src/controllers/user.go +++ b/backend/src/controllers/user.go @@ -137,3 +137,25 @@ func (u *UserController) DeleteUser(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } + +func (u *UserController) GetUserTags(c *fiber.Ctx) error { + tags, err := u.userService.GetUserTags(c.Params("uid")) + if err != nil { + return err.FiberError(c) + } + return c.Status(fiber.StatusOK).JSON(&tags) +} + +func (u *UserController) CreateUserTags(c *fiber.Ctx) error { + var requestBody models.CreateUserTagsBody + if err := c.BodyParser(&requestBody); err != nil { + return errors.FailedToParseRequestBody.FiberError(c) + } + + tags, err := u.userService.CreateUserTags(c.Params("uid"), requestBody) + if err != nil { + return err.FiberError(c) + } + + return c.Status(fiber.StatusCreated).JSON(&tags) +} diff --git a/backend/src/errors/user.go b/backend/src/errors/user.go index 3d63b02fc..dd80dbb98 100644 --- a/backend/src/errors/user.go +++ b/backend/src/errors/user.go @@ -7,6 +7,10 @@ var ( StatusCode: fiber.StatusBadRequest, Message: "failed to validate user", } + FailedToValidateUserTags = Error { + StatusCode: fiber.StatusBadRequest, + Message: "failed to validate user tags", + } FailedToCreateUser = Error{ StatusCode: fiber.StatusInternalServerError, Message: "failed to create user", diff --git a/backend/src/models/tag.go b/backend/src/models/tag.go index 471b8b2ae..36c71381a 100644 --- a/backend/src/models/tag.go +++ b/backend/src/models/tag.go @@ -7,7 +7,7 @@ type Tag struct { Name string `gorm:"type:varchar(255)" json:"name" validate:"required,max=255"` - CategoryID uuid.UUID `gorm:"foreignKey:CategoryID" json:"category_id" validate:"required,uuid4"` + CategoryID uuid.UUID `json:"category_id" validate:"required,uuid4"` User []User `gorm:"many2many:user_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-" validate:"-"` Club []Club `gorm:"many2many:club_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-" validate:"-"` diff --git a/backend/src/models/user.go b/backend/src/models/user.go index 1242ee4e3..ee762ab5f 100644 --- a/backend/src/models/user.go +++ b/backend/src/models/user.go @@ -1,5 +1,7 @@ package models +import "github.com/google/uuid" + type UserRole string const ( @@ -74,3 +76,7 @@ type UpdateUserRequestBody struct { College College `json:"college" validate:"omitempty,oneof=CAMD DMSB KCCS CE BCHS SL CPS CS CSSH"` Year Year `json:"year" validate:"omitempty,min=1,max=6"` } + +type CreateUserTagsBody struct { + Tags []uuid.UUID `json:"tags" validate:"required"` +} diff --git a/backend/src/server/server.go b/backend/src/server/server.go index a4181a4f9..c529e285d 100644 --- a/backend/src/server/server.go +++ b/backend/src/server/server.go @@ -74,6 +74,11 @@ func userRoutes(router fiber.Router, userService services.UserServiceInterface) users.Get("/:id", userController.GetUser) users.Patch("/:id", userController.UpdateUser) users.Delete("/:id", userController.DeleteUser) + + userTags := users.Group("/:uid/tags") + + userTags.Post("/", userController.CreateUserTags) + userTags.Get("/", userController.GetUserTags) } func clubRoutes(router fiber.Router, clubService services.ClubServiceInterface) { diff --git a/backend/src/services/user.go b/backend/src/services/user.go index d77d9cd79..678a6fd61 100644 --- a/backend/src/services/user.go +++ b/backend/src/services/user.go @@ -19,6 +19,8 @@ type UserServiceInterface interface { GetUser(id string) (*models.User, *errors.Error) UpdateUser(id string, userBody models.UpdateUserRequestBody) (*models.User, *errors.Error) DeleteUser(id string) *errors.Error + GetUserTags(id string) ([]models.Tag, *errors.Error) + CreateUserTags(id string, tagIDs models.CreateUserTagsBody) ([]models.Tag, *errors.Error) } type UserService struct { @@ -107,3 +109,30 @@ func (u *UserService) DeleteUser(id string) *errors.Error { return transactions.DeleteUser(u.DB, *idAsUUID) } + +func (u *UserService) GetUserTags(id string) ([]models.Tag, *errors.Error) { + idAsUUID, err := utilities.ValidateID(id) + if err != nil { + return nil, err + } + + return transactions.GetUserTags(u.DB, *idAsUUID) +} + +func (u *UserService) CreateUserTags(id string, tagIDs models.CreateUserTagsBody) ([]models.Tag, *errors.Error) { + // Validate the id: + idAsUUID, err := utilities.ValidateID(id) + if err != nil { + return nil, err + } + + if err := u.Validate.Struct(tagIDs); err != nil { + return nil, &errors.FailedToValidateUserTags + } + + // Retrieve a list of valid tags from the ids: + tags, err := transactions.GetTagsByIDs(u.DB, tagIDs.Tags) + + // Update the user to reflect the new tags: + return transactions.CreateUserTags(u.DB, *idAsUUID, tags) +} diff --git a/backend/src/transactions/tag.go b/backend/src/transactions/tag.go index cefc99740..2101ae177 100644 --- a/backend/src/transactions/tag.go +++ b/backend/src/transactions/tag.go @@ -2,7 +2,6 @@ package transactions import ( stdliberrors "errors" - "github.com/GenerateNU/sac/backend/src/errors" "github.com/google/uuid" @@ -56,3 +55,15 @@ func DeleteTag(db *gorm.DB, id uuid.UUID) *errors.Error { return nil } + +func GetTagsByIDs(db *gorm.DB, selectedTagIDs []uuid.UUID) ([]models.Tag, *errors.Error) { + if len(selectedTagIDs) != 0 { + var tags []models.Tag + if err := db.Model(models.Tag{}).Where("id IN ?", selectedTagIDs).Find(&tags).Error; err != nil { + return nil, &errors.FailedToGetTag + } + + return tags, nil + } + return []models.Tag{}, nil +} diff --git a/backend/src/transactions/user.go b/backend/src/transactions/user.go index 75d51b147..3f4540b24 100644 --- a/backend/src/transactions/user.go +++ b/backend/src/transactions/user.go @@ -75,3 +75,30 @@ func DeleteUser(db *gorm.DB, id uuid.UUID) *errors.Error { } return nil } + +func GetUserTags(db *gorm.DB, id uuid.UUID) ([]models.Tag, *errors.Error) { + var tags []models.Tag + + user, err := GetUser(db, id) + if err != nil { + return nil, &errors.UserNotFound + } + + if err := db.Model(&user).Association("Tag").Find(&tags) ; err != nil { + return nil, &errors.FailedToGetTag + } + return tags, nil +} + +func CreateUserTags(db *gorm.DB, id uuid.UUID, tags []models.Tag) ([]models.Tag, *errors.Error) { + user, err := GetUser(db, id) + if err != nil { + return nil, &errors.UserNotFound + } + + if err := db.Model(&user).Association("Tag").Replace(tags); err != nil { + return nil, &errors.FailedToUpdateUser + } + + return tags, nil +} diff --git a/backend/tests/api/user_test.go b/backend/tests/api/user_test.go index 15a6ac80c..f1cd872d9 100644 --- a/backend/tests/api/user_test.go +++ b/backend/tests/api/user_test.go @@ -553,3 +553,312 @@ func TestCreateUserFailsOnMissingFields(t *testing.T) { } appAssert.Close() } + +func SampleCategoriesFactory() *[]map[string]interface{} { + return &[]map[string]interface{}{ + { + "name": "Business", + }, + { + "name": "STEM", + }, + } +} + +func SampleTagsFactory(categoryIDs []uuid.UUID) *[]map[string]interface{} { + lenOfIDs := len(categoryIDs) + + return &[]map[string]interface{}{ + { + "name": "Computer Science", + "category_id": categoryIDs[1%lenOfIDs], + }, + { + "name": "Mechanical Engineering", + "category_id": categoryIDs[1%lenOfIDs], + }, + { + "name": "Finance", + "category_id": categoryIDs[0%lenOfIDs], + }, + } +} + +func SampleTagIDsFactory(tagIDs *[]uuid.UUID) *map[string]interface{} { + tags := tagIDs + + if tags == nil { + tags = &[]uuid.UUID{uuid.New()} + } + + return &map[string]interface{}{ + "tags": tags, + } +} + +func CreateSetOfTags(t *testing.T, appAssert ExistingAppAssert) []uuid.UUID { + categories := SampleCategoriesFactory() + + categoryIDs := []uuid.UUID{} + for _, category := range *categories { + TestRequest{ + Method: fiber.MethodPost, + Path: "/api/v1/categories/", + Body: &category, + }.TestOnStatusAndDB(t, &appAssert, + DBTesterWithStatus{ + Status: fiber.StatusCreated, + DBTester: func(app TestApp, assert *assert.A, resp *http.Response) { + var respCategory models.Category + + err := json.NewDecoder(resp.Body).Decode(&respCategory) + + assert.NilError(err) + + categoryIDs = append(categoryIDs, respCategory.ID) + }, + }, + ) + } + + tags := SampleTagsFactory(categoryIDs) + + + tagIDs := []uuid.UUID{} + for _, tag := range *tags { + TestRequest{ + Method: fiber.MethodPost, + Path: "/api/v1/tags/", + Body: &tag, + }.TestOnStatusAndDB(t, &appAssert, + DBTesterWithStatus{ + Status: fiber.StatusCreated, + DBTester: func(app TestApp, assert *assert.A, resp *http.Response) { + var respTag models.Tag + + err := json.NewDecoder(resp.Body).Decode(&respTag) + + assert.NilError(err) + + tagIDs = append(tagIDs, respTag.ID) + }, + }, + ) + } + + return tagIDs +} + +func AssertUserTagsRespDB(app TestApp, assert *assert.A, resp *http.Response, id uuid.UUID) { + var respTags []models.Tag + + // Retrieve the tags from the response: + err := json.NewDecoder(resp.Body).Decode(&respTags) + + assert.NilError(err) + + // Retrieve the user connected to the tags: + var dbUser models.User + err = app.Conn.First(&dbUser, id).Error + + assert.NilError(err) + + // Retrieve the tags in the bridge table associated with the user: + var dbTags []models.Tag + err = app.Conn.Model(&dbUser).Association("Tag").Find(&dbTags) + + assert.NilError(err) + + // Confirm all the resp tags are equal to the db tags: + for i, respTag := range respTags { + assert.Equal(respTag.ID, dbTags[i].ID) + assert.Equal(respTag.Name, dbTags[i].Name) + assert.Equal(respTag.CategoryID, dbTags[i].CategoryID) + } +} + +func AssertSampleUserTagsRespDB(app TestApp, assert *assert.A, resp *http.Response, uuid uuid.UUID) { + AssertUserTagsRespDB(app, assert, resp, uuid) +} + +func TestCreateUserTagsFailsOnInvalidDataType(t *testing.T) { + // Invalid tag data types: + invalidTags := []interface{}{ + []string{"1", "2", "34"}, + []models.Tag{{Name: "Test", CategoryID: uuid.UUID{}}, {Name: "Test2", CategoryID: uuid.UUID{}}}, + []float32{1.32, 23.5, 35.1}, + } + + // Test each of the invalid tags: + for _, tag := range invalidTags { + malformedTag := *SampleTagIDsFactory(nil) + malformedTag["tags"] = tag + + TestRequest{ + Method: fiber.MethodPost, + Path: "/api/v1/users/1/tags/", + Body: &malformedTag, + }.TestOnError(t, nil, errors.FailedToParseRequestBody).Close() + } +} + +func TestCreateUserTagsFailsOnInvalidUserID(t *testing.T) { + badRequests := []string{ + "0", + "-1", + "1.1", + "foo", + "null", + } + + for _, badRequest := range badRequests { + TestRequest{ + Method: fiber.MethodPost, + Path: fmt.Sprintf("/api/v1/users/%s/tags", badRequest), + Body: SampleTagIDsFactory(nil), + }.TestOnError(t, nil, errors.FailedToValidateID).Close() + } +} + +type UUIDSlice []uuid.UUID + +var testUUID = uuid.New() + +func TestCreateUserTagsFailsOnInvalidKey(t *testing.T) { + appAssert, uuid := CreateSampleUser(t, nil) + + invalidBody := []map[string]interface{}{ + { + "tag": UUIDSlice{testUUID, testUUID}, + }, + { + "tagIDs": []uint{1, 2, 3}, + }, + } + + for _, body := range invalidBody { + TestRequest{ + Method: fiber.MethodPost, + Path: fmt.Sprintf("/api/v1/users/%s/tags/", uuid), + Body: &body, + }.TestOnError(t, &appAssert, errors.FailedToValidateUserTags) + } + + appAssert.Close() +} + +func TestCreateUserTagsFailsOnNonExistentUser(t *testing.T) { + TestRequest{ + Method: fiber.MethodPost, + Path: fmt.Sprintf("/api/v1/users/%s/tags", uuid.New()), + Body: SampleTagIDsFactory(nil), + }.TestOnError(t, nil, errors.UserNotFound).Close() +} + +func TestCreateUserTagsWorks(t *testing.T) { + appAssert, uuid := CreateSampleUser(t, nil) + + // Create a set of tags: + tagUUIDs := CreateSetOfTags(t, appAssert) + + // Confirm adding real tags adds them to the user: + TestRequest{ + Method: fiber.MethodPost, + Path: fmt.Sprintf("/api/v1/users/%s/tags/", uuid), + Body: SampleTagIDsFactory(&tagUUIDs), + }.TestOnStatusAndDB(t, &appAssert, + DBTesterWithStatus{ + Status: fiber.StatusCreated, + DBTester: func(app TestApp, assert *assert.A, resp *http.Response) { + AssertSampleUserTagsRespDB(app, assert, resp, uuid) + }, + }, + ) + + appAssert.Close() +} + +func TestCreateUserTagsNoneAddedIfInvalid(t *testing.T) { + appAssert, uuid := CreateSampleUser(t, nil) + + TestRequest{ + Method: fiber.MethodPost, + Path: fmt.Sprintf("/api/v1/users/%s/tags/", uuid), + Body: SampleTagIDsFactory(nil), + }.TestOnStatusAndDB(t, &appAssert, + DBTesterWithStatus{ + Status: fiber.StatusCreated, + DBTester: func(app TestApp, assert *assert.A, resp *http.Response) { + var respTags []models.Tag + + err := json.NewDecoder(resp.Body).Decode(&respTags) + + assert.NilError(err) + + assert.Equal(len(respTags), 0) + }, + }, + ) + + appAssert.Close() +} + +func TestGetUserTagsFailsOnNonExistentUser(t *testing.T) { + TestRequest{ + Method: fiber.MethodGet, + Path: fmt.Sprintf("/api/v1/users/%s/tags/", uuid.New()), + }.TestOnError(t, nil, errors.UserNotFound).Close() +} + +func TestGetUserTagsReturnsEmptyListWhenNoneAdded(t *testing.T) { + appAssert, uuid := CreateSampleUser(t, nil) + + TestRequest{ + Method: fiber.MethodGet, + Path: fmt.Sprintf("/api/v1/users/%s/tags/", uuid), + }.TestOnStatusAndDB(t, &appAssert, + DBTesterWithStatus{ + Status: 200, + DBTester: func(app TestApp, assert *assert.A, resp *http.Response) { + var respTags []models.Tag + + err := json.NewDecoder(resp.Body).Decode(&respTags) + + assert.NilError(err) + + assert.Equal(len(respTags), 0) + }, + }, + ) + + appAssert.Close() +} + +func TestGetUserTagsReturnsCorrectList(t *testing.T) { + appAssert, uuid := CreateSampleUser(t, nil) + + // Create a set of tags: + tagUUIDs := CreateSetOfTags(t, appAssert) + + // Add the tags: + TestRequest{ + Method: fiber.MethodPost, + Path: fmt.Sprintf("/api/v1/users/%s/tags/", uuid), + Body: SampleTagIDsFactory(&tagUUIDs), + }.TestOnStatus(t, &appAssert, fiber.StatusCreated) + + // Get the tags: + TestRequest{ + Method: fiber.MethodGet, + Path: fmt.Sprintf("/api/v1/users/%s/tags/", uuid), + }.TestOnStatusAndDB(t, &appAssert, + DBTesterWithStatus{ + Status: fiber.StatusOK, + DBTester: func(app TestApp, assert *assert.A, resp *http.Response) { + AssertSampleUserTagsRespDB(app, assert, resp, uuid) + }, + }, + ) + + appAssert.Close() +}