From 926723a12e96716a4a3a4a200076c959ff13fba0 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sat, 19 Nov 2022 17:26:42 +0100 Subject: [PATCH] add item tags to backend --- entities/item.go | 32 +++++ internal/genutils/retain.go | 17 +++ internal/validate/tag.go | 7 ++ models/errors.go | 1 + models/item.go | 3 + ports/mysql/db.go | 8 +- ports/mysql/items.go | 113 +++++++++++++++++- ports/mysql/mysqlcore/db.go | 40 +++++++ ports/mysql/mysqlcore/models.go | 6 + ports/mysql/mysqlcore/tag.sql.go | 97 +++++++++++++++ ports/mysql/queries/tag.sql | 22 ++++ .../20221119134102_create_table_tag.sql | 16 +++ usecases/items/service.go | 54 +++++++++ 13 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 internal/genutils/retain.go create mode 100644 internal/validate/tag.go create mode 100644 ports/mysql/mysqlcore/tag.sql.go create mode 100644 ports/mysql/queries/tag.sql create mode 100644 scripts/goose-mysql/20221119134102_create_table_tag.sql diff --git a/entities/item.go b/entities/item.go index 9f36572..6bf49a4 100644 --- a/entities/item.go +++ b/entities/item.go @@ -2,6 +2,8 @@ package entities import ( "git.aiterp.net/stufflog3/stufflog3/models" + "sort" + "strings" "time" ) @@ -13,6 +15,7 @@ type Item struct { RequirementID *int `json:"requirementId,omitempty"` Name string `json:"name"` Description string `json:"description"` + Tags []string `json:"tags"` CreatedTime time.Time `json:"createdTime"` AcquiredTime *time.Time `json:"acquiredTime"` ScheduledDate *models.Date `json:"scheduledDate"` @@ -33,6 +36,16 @@ func (item *Item) AcquiredBetween(fromTime, toTime time.Time) bool { return item.AcquiredTime != nil && !item.AcquiredTime.Before(fromTime) && item.AcquiredTime.Before(toTime) } +func (item *Item) HasTag(tag string) bool { + for _, tag2 := range item.Tags { + if strings.EqualFold(tag, tag2) { + return true + } + } + + return false +} + func (item *Item) ApplyUpdate(update models.ItemUpdate) { if update.RequirementID != nil { if *update.RequirementID <= 0 { @@ -62,4 +75,23 @@ func (item *Item) ApplyUpdate(update models.ItemUpdate) { if update.ClearAcquiredTime { item.AcquiredTime = nil } + if update.RemoveTags != nil || update.AddTags != nil { + item.Tags = append(item.Tags[:0:0], item.Tags...) + for _, tagToRemove := range update.RemoveTags { + for i, tag := range item.Tags { + if strings.EqualFold(tag, tagToRemove) { + { + item.Tags = append(item.Tags[:i], item.Tags[i+1:]...) + break + } + } + } + } + for _, tagToAdd := range update.AddTags { + item.Tags = append(item.Tags, tagToAdd) + } + if len(update.AddTags) > 0 { + sort.Strings(item.Tags) + } + } } diff --git a/internal/genutils/retain.go b/internal/genutils/retain.go new file mode 100644 index 0000000..79875f3 --- /dev/null +++ b/internal/genutils/retain.go @@ -0,0 +1,17 @@ +package genutils + +func Retain[T any](arr []T, cb func(T) bool) []T { + return RetainInPlace(append(arr[:0:0], arr...), cb) +} + +func RetainInPlace[T any](arr []T, cb func(T) bool) []T { + count := 0 + for _, v := range arr { + if cb(v) { + arr[count] = v + count += 1 + } + } + + return arr[:count] +} diff --git a/internal/validate/tag.go b/internal/validate/tag.go new file mode 100644 index 0000000..a5264c4 --- /dev/null +++ b/internal/validate/tag.go @@ -0,0 +1,7 @@ +package validate + +import "strings" + +func Tag(s string) bool { + return !strings.ContainsAny(s, ",;\t\r\n <>") +} diff --git a/models/errors.go b/models/errors.go index 71e1981..2499be7 100644 --- a/models/errors.go +++ b/models/errors.go @@ -55,6 +55,7 @@ type BadInputError struct { Problem string `json:"problem"` Min interface{} `json:"min,omitempty"` Max interface{} `json:"max,omitempty"` + Element interface{} `json:"element,omitempty"` } func (e BadInputError) Error() string { diff --git a/models/item.go b/models/item.go index 188d5a4..bde9044 100644 --- a/models/item.go +++ b/models/item.go @@ -9,6 +9,8 @@ type ItemUpdate struct { Description *string `json:"description"` AcquiredTime *time.Time `json:"acquiredTime"` ScheduledDate *Date `json:"scheduledDate"` + AddTags []string `json:"addTags"` + RemoveTags []string `json:"removeTags"` ClearAcquiredTime bool `json:"clearAcquiredTime"` ClearScheduledDate bool `json:"clearScheduledDate"` @@ -24,6 +26,7 @@ type ItemFilter struct { ProjectIDs []int `json:"projectIds,omitempty"` RequirementIDs []int `json:"requirementIds,omitempty"` StatIDs []int `json:"statIds,omitempty"` + Tags []string `json:"tags,omitempty"` Loose bool `json:"loose,omitempty"` UnScheduled bool `json:"unScheduled,omitempty"` UnAcquired bool `json:"unAcquired,omitempty"` diff --git a/ports/mysql/db.go b/ports/mysql/db.go index 4084770..6c46543 100644 --- a/ports/mysql/db.go +++ b/ports/mysql/db.go @@ -75,7 +75,7 @@ func Connect(host string, port int, username, password, database string) (*Datab } q := mysqlcore.New(db) - + return &Database{db: db, q: q}, nil } @@ -129,3 +129,9 @@ func sqlJsonPtr(ptr interface{}) sqltypes.NullRawMessage { return sqltypes.NullRawMessage{Valid: false} } } + +const ( + tagObjectKindItem = iota + tagObjectKindRequirement + tagObjectKindProject +) diff --git a/ports/mysql/items.go b/ports/mysql/items.go index 845388f..d75f528 100644 --- a/ports/mysql/items.go +++ b/ports/mysql/items.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "git.aiterp.net/stufflog3/stufflog3/entities" + "git.aiterp.net/stufflog3/stufflog3/internal/genutils" "git.aiterp.net/stufflog3/stufflog3/models" "git.aiterp.net/stufflog3/stufflog3/ports/mysql/mysqlcore" "git.aiterp.net/stufflog3/stufflog3/ports/mysql/sqltypes" @@ -23,6 +24,11 @@ func (r *itemRepository) Find(ctx context.Context, scopeID, itemID int) (*entiti return nil, err } + tags, err := r.q.ListTagsByObject(ctx, mysqlcore.ListTagsByObjectParams{ + ObjectKind: tagObjectKindItem, + ObjectID: row.ID, + }) + return &entities.Item{ ID: row.ID, ScopeID: row.ScopeID, @@ -34,6 +40,7 @@ func (r *itemRepository) Find(ctx context.Context, scopeID, itemID int) (*entiti CreatedTime: row.CreatedTime, AcquiredTime: timePtr(row.AcquiredTime), ScheduledDate: row.ScheduledDate.AsPtr(), + Tags: tags, }, nil } @@ -54,6 +61,50 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([ if filter.StatIDs != nil && len(filter.StatIDs) == 0 { return []entities.Item{}, nil } + if filter.Tags != nil && len(filter.Tags) == 0 { + return []entities.Item{}, nil + } + + // For tags, use the ID filter to avoid making too much of a messy query below. + if filter.Tags != nil { + query, args, err := squirrel.Select("object_id, tag_name"). + From("tag"). + Where(squirrel.Eq{"tag_name": filter.Tags, "object_kind": tagObjectKindItem}). + ToSql() + if err != nil { + return nil, err + } + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + ids := make([]int, 0, 16) + matches := make(map[int]int, 64) + for rows.Next() { + var objectID int + var tagName string + err := rows.Scan(&objectID, &tagName) + if err != nil { + return nil, err + } + if matches[objectID] == 0 { + ids = append(ids, objectID) + } + matches[objectID] += 1 + } + + err = rows.Close() + if err != nil { + return nil, err + } + + if filter.IDs == nil { + filter.IDs = ids + } + filter.IDs = genutils.RetainInPlace(filter.IDs, func(id int) bool { + return matches[id] == len(filter.Tags) + }) + } sq := squirrel.Select( "i.id, i.scope_id, i.project_requirement_id, pr.project_id, i.owner_id, i.name," + @@ -208,7 +259,14 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([ } func (r *itemRepository) Insert(ctx context.Context, item entities.Item) (*entities.Item, error) { - res, err := r.q.InsertItem(ctx, mysqlcore.InsertItemParams{ + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + q := mysqlcore.New(tx) + + res, err := q.InsertItem(ctx, mysqlcore.InsertItemParams{ ScopeID: item.ScopeID, ProjectRequirementID: sqlIntPtr(item.RequirementID), Name: item.Name, @@ -227,13 +285,37 @@ func (r *itemRepository) Insert(ctx context.Context, item entities.Item) (*entit } item.ID = int(id) + + for _, tag := range item.Tags { + err := q.InsertTag(ctx, mysqlcore.InsertTagParams{ + ObjectKind: tagObjectKindItem, + ObjectID: item.ID, + TagName: tag, + }) + if err != nil { + return nil, err + } + } + + err = tx.Commit() + if err != nil { + return nil, err + } + return &item, nil } func (r *itemRepository) Update(ctx context.Context, item entities.Item, update models.ItemUpdate) error { item.ApplyUpdate(update) - return r.q.UpdateItem(ctx, mysqlcore.UpdateItemParams{ + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + q := mysqlcore.New(tx) + + err = q.UpdateItem(ctx, mysqlcore.UpdateItemParams{ ProjectRequirementID: sqlIntPtr(item.RequirementID), Name: item.Name, Description: item.Description, @@ -242,6 +324,33 @@ func (r *itemRepository) Update(ctx context.Context, item entities.Item, update OwnerID: item.OwnerID, ID: item.ID, }) + if err != nil { + return err + } + + for _, tag := range update.RemoveTags { + err := q.DeleteTag(ctx, mysqlcore.DeleteTagParams{ + ObjectKind: tagObjectKindItem, + ObjectID: item.ID, + TagName: tag, + }) + if err != nil { + return err + } + } + + for _, tag := range update.AddTags { + err = q.InsertTag(ctx, mysqlcore.InsertTagParams{ + ObjectKind: tagObjectKindItem, + ObjectID: item.ID, + TagName: tag, + }) + if err != nil { + return err + } + } + + return tx.Commit() } func (r *itemRepository) Delete(ctx context.Context, item entities.Item) error { diff --git a/ports/mysql/mysqlcore/db.go b/ports/mysql/mysqlcore/db.go index 9d5a93d..f96dd5e 100644 --- a/ports/mysql/mysqlcore/db.go +++ b/ports/mysql/mysqlcore/db.go @@ -102,6 +102,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteStatStmt, err = db.PrepareContext(ctx, deleteStat); err != nil { return nil, fmt.Errorf("error preparing query DeleteStat: %w", err) } + if q.deleteTagStmt, err = db.PrepareContext(ctx, deleteTag); err != nil { + return nil, fmt.Errorf("error preparing query DeleteTag: %w", err) + } + if q.deleteTagByObjectStmt, err = db.PrepareContext(ctx, deleteTagByObject); err != nil { + return nil, fmt.Errorf("error preparing query DeleteTagByObject: %w", err) + } if q.getItemStmt, err = db.PrepareContext(ctx, getItem); err != nil { return nil, fmt.Errorf("error preparing query GetItem: %w", err) } @@ -138,6 +144,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertStatStmt, err = db.PrepareContext(ctx, insertStat); err != nil { return nil, fmt.Errorf("error preparing query InsertStat: %w", err) } + if q.insertTagStmt, err = db.PrepareContext(ctx, insertTag); err != nil { + return nil, fmt.Errorf("error preparing query InsertTag: %w", err) + } if q.listItemStatProgressStmt, err = db.PrepareContext(ctx, listItemStatProgress); err != nil { return nil, fmt.Errorf("error preparing query ListItemStatProgress: %w", err) } @@ -201,6 +210,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listStatsStmt, err = db.PrepareContext(ctx, listStats); err != nil { return nil, fmt.Errorf("error preparing query ListStats: %w", err) } + if q.listTagsByObjectStmt, err = db.PrepareContext(ctx, listTagsByObject); err != nil { + return nil, fmt.Errorf("error preparing query ListTagsByObject: %w", err) + } if q.replaceItemStatProgressStmt, err = db.PrepareContext(ctx, replaceItemStatProgress); err != nil { return nil, fmt.Errorf("error preparing query ReplaceItemStatProgress: %w", err) } @@ -366,6 +378,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteStatStmt: %w", cerr) } } + if q.deleteTagStmt != nil { + if cerr := q.deleteTagStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteTagStmt: %w", cerr) + } + } + if q.deleteTagByObjectStmt != nil { + if cerr := q.deleteTagByObjectStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteTagByObjectStmt: %w", cerr) + } + } if q.getItemStmt != nil { if cerr := q.getItemStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getItemStmt: %w", cerr) @@ -426,6 +448,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertStatStmt: %w", cerr) } } + if q.insertTagStmt != nil { + if cerr := q.insertTagStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTagStmt: %w", cerr) + } + } if q.listItemStatProgressStmt != nil { if cerr := q.listItemStatProgressStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listItemStatProgressStmt: %w", cerr) @@ -531,6 +558,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listStatsStmt: %w", cerr) } } + if q.listTagsByObjectStmt != nil { + if cerr := q.listTagsByObjectStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTagsByObjectStmt: %w", cerr) + } + } if q.replaceItemStatProgressStmt != nil { if cerr := q.replaceItemStatProgressStmt.Close(); cerr != nil { err = fmt.Errorf("error closing replaceItemStatProgressStmt: %w", cerr) @@ -646,6 +678,8 @@ type Queries struct { deleteSprintStmt *sql.Stmt deleteSprintPartStmt *sql.Stmt deleteStatStmt *sql.Stmt + deleteTagStmt *sql.Stmt + deleteTagByObjectStmt *sql.Stmt getItemStmt *sql.Stmt getItemStatProgressBetweenStmt *sql.Stmt getProjectStmt *sql.Stmt @@ -658,6 +692,7 @@ type Queries struct { insertScopeStmt *sql.Stmt insertSprintStmt *sql.Stmt insertStatStmt *sql.Stmt + insertTagStmt *sql.Stmt listItemStatProgressStmt *sql.Stmt listItemStatProgressMultiStmt *sql.Stmt listItemsAcquiredBetweenStmt *sql.Stmt @@ -679,6 +714,7 @@ type Queries struct { listSprintsAtStmt *sql.Stmt listSprintsBetweenStmt *sql.Stmt listStatsStmt *sql.Stmt + listTagsByObjectStmt *sql.Stmt replaceItemStatProgressStmt *sql.Stmt replaceProjectRequirementStatStmt *sql.Stmt replaceScopeMemberStmt *sql.Stmt @@ -721,6 +757,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { deleteSprintStmt: q.deleteSprintStmt, deleteSprintPartStmt: q.deleteSprintPartStmt, deleteStatStmt: q.deleteStatStmt, + deleteTagStmt: q.deleteTagStmt, + deleteTagByObjectStmt: q.deleteTagByObjectStmt, getItemStmt: q.getItemStmt, getItemStatProgressBetweenStmt: q.getItemStatProgressBetweenStmt, getProjectStmt: q.getProjectStmt, @@ -733,6 +771,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { insertScopeStmt: q.insertScopeStmt, insertSprintStmt: q.insertSprintStmt, insertStatStmt: q.insertStatStmt, + insertTagStmt: q.insertTagStmt, listItemStatProgressStmt: q.listItemStatProgressStmt, listItemStatProgressMultiStmt: q.listItemStatProgressMultiStmt, listItemsAcquiredBetweenStmt: q.listItemsAcquiredBetweenStmt, @@ -754,6 +793,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listSprintsAtStmt: q.listSprintsAtStmt, listSprintsBetweenStmt: q.listSprintsBetweenStmt, listStatsStmt: q.listStatsStmt, + listTagsByObjectStmt: q.listTagsByObjectStmt, replaceItemStatProgressStmt: q.replaceItemStatProgressStmt, replaceProjectRequirementStatStmt: q.replaceProjectRequirementStatStmt, replaceScopeMemberStmt: q.replaceScopeMemberStmt, diff --git a/ports/mysql/mysqlcore/models.go b/ports/mysql/mysqlcore/models.go index 4865856..3df7587 100644 --- a/ports/mysql/mysqlcore/models.go +++ b/ports/mysql/mysqlcore/models.go @@ -101,3 +101,9 @@ type Stat struct { AllowedAmounts sqltypes.NullRawMessage IsPrimary bool } + +type Tag struct { + ObjectKind int32 + ObjectID int + TagName string +} diff --git a/ports/mysql/mysqlcore/tag.sql.go b/ports/mysql/mysqlcore/tag.sql.go new file mode 100644 index 0000000..347ccc5 --- /dev/null +++ b/ports/mysql/mysqlcore/tag.sql.go @@ -0,0 +1,97 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.13.0 +// source: tag.sql + +package mysqlcore + +import ( + "context" +) + +const deleteTag = `-- name: DeleteTag :exec +DELETE +FROM tag +WHERE object_kind = ? + AND object_id = ? + AND tag_name = ? +` + +type DeleteTagParams struct { + ObjectKind int32 + ObjectID int + TagName string +} + +func (q *Queries) DeleteTag(ctx context.Context, arg DeleteTagParams) error { + _, err := q.exec(ctx, q.deleteTagStmt, deleteTag, arg.ObjectKind, arg.ObjectID, arg.TagName) + return err +} + +const deleteTagByObject = `-- name: DeleteTagByObject :exec +DELETE +FROM tag +WHERE object_kind = ? + AND object_id = ? +` + +type DeleteTagByObjectParams struct { + ObjectKind int32 + ObjectID int +} + +func (q *Queries) DeleteTagByObject(ctx context.Context, arg DeleteTagByObjectParams) error { + _, err := q.exec(ctx, q.deleteTagByObjectStmt, deleteTagByObject, arg.ObjectKind, arg.ObjectID) + return err +} + +const insertTag = `-- name: InsertTag :exec +INSERT INTO tag (object_kind, object_id, tag_name) +VALUES (?, ?, ?) +` + +type InsertTagParams struct { + ObjectKind int32 + ObjectID int + TagName string +} + +func (q *Queries) InsertTag(ctx context.Context, arg InsertTagParams) error { + _, err := q.exec(ctx, q.insertTagStmt, insertTag, arg.ObjectKind, arg.ObjectID, arg.TagName) + return err +} + +const listTagsByObject = `-- name: ListTagsByObject :many +SELECT tag_name +FROM tag +WHERE object_kind = ? + AND object_id = ? +` + +type ListTagsByObjectParams struct { + ObjectKind int32 + ObjectID int +} + +func (q *Queries) ListTagsByObject(ctx context.Context, arg ListTagsByObjectParams) ([]string, error) { + rows, err := q.query(ctx, q.listTagsByObjectStmt, listTagsByObject, arg.ObjectKind, arg.ObjectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var tag_name string + if err := rows.Scan(&tag_name); err != nil { + return nil, err + } + items = append(items, tag_name) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/ports/mysql/queries/tag.sql b/ports/mysql/queries/tag.sql new file mode 100644 index 0000000..63b3d5a --- /dev/null +++ b/ports/mysql/queries/tag.sql @@ -0,0 +1,22 @@ +-- name: InsertTag :exec +INSERT INTO tag (object_kind, object_id, tag_name) +VALUES (?, ?, ?); + +-- name: DeleteTag :exec +DELETE +FROM tag +WHERE object_kind = ? + AND object_id = ? + AND tag_name = ?; + +-- name: DeleteTagByObject :exec +DELETE +FROM tag +WHERE object_kind = ? + AND object_id = ?; + +-- name: ListTagsByObject :many +SELECT tag_name +FROM tag +WHERE object_kind = ? + AND object_id = ?; diff --git a/scripts/goose-mysql/20221119134102_create_table_tag.sql b/scripts/goose-mysql/20221119134102_create_table_tag.sql new file mode 100644 index 0000000..25e1663 --- /dev/null +++ b/scripts/goose-mysql/20221119134102_create_table_tag.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE tag ( + object_kind TINYINT NOT NULL, + object_id INT NOT NULL, + tag_name VARCHAR(255) NOT NULL, + + PRIMARY KEY (object_kind, object_id, tag_name), + UNIQUE (object_kind, tag_name, object_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS tag; +-- +goose StatementEnd diff --git a/usecases/items/service.go b/usecases/items/service.go index 1c7db4e..08e3ddd 100644 --- a/usecases/items/service.go +++ b/usecases/items/service.go @@ -2,8 +2,10 @@ package items import ( "context" + "fmt" "git.aiterp.net/stufflog3/stufflog3/entities" "git.aiterp.net/stufflog3/stufflog3/internal/genutils" + "git.aiterp.net/stufflog3/stufflog3/internal/validate" "git.aiterp.net/stufflog3/stufflog3/models" "git.aiterp.net/stufflog3/stufflog3/usecases/auth" "git.aiterp.net/stufflog3/stufflog3/usecases/scopes" @@ -214,6 +216,28 @@ func (s *Service) Create(ctx context.Context, item entities.Item, stats []entiti scope := s.Scopes.Context(ctx).Scope user := s.Auth.GetUser(ctx) + for i, tag := range item.Tags { + if !validate.Tag(tag) { + return nil, models.BadInputError{ + Object: "ItemInput", + Field: "tags", + Problem: fmt.Sprintf("Invalid tag: %s", tag), + Element: tag, + } + } + + for _, prevTag := range item.Tags[:i] { + if strings.EqualFold(tag, prevTag) { + return nil, models.BadInputError{ + Object: "ItemInput", + Field: "tags", + Problem: fmt.Sprintf("Duplicate tag: %s", tag), + Element: tag, + } + } + } + } + item.Name = strings.Trim(item.Name, "  \t\r\n") if item.Name == "" { return nil, models.BadInputError{ @@ -290,6 +314,36 @@ func (s *Service) Update(ctx context.Context, id int, update models.ItemUpdate, return nil, err } + for _, tag := range update.AddTags { + if !validate.Tag(tag) { + return nil, models.BadInputError{ + Object: "ItemInput", + Field: "addTags", + Problem: fmt.Sprintf("Invalid tag: %s", tag), + Element: tag, + } + } + + if item.HasTag(tag) { + return nil, models.BadInputError{ + Object: "ItemUpdate", + Field: "addTags", + Problem: fmt.Sprintf("The tag %s already exists!", tag), + Element: tag, + } + } + } + for _, tag := range update.RemoveTags { + if !item.HasTag(tag) { + return nil, models.BadInputError{ + Object: "ItemUpdate", + Field: "removeTags", + Problem: fmt.Sprintf("The tag %s does not exist!", tag), + Element: tag, + } + } + } + if update.RequirementID != nil { if *update.RequirementID > 0 { req, _, err := s.RequirementFetcher.FetchRequirements(ctx, scope.ID, *update.RequirementID)