diff --git a/doc/api.oa3 b/doc/api.oa3 index 4ff0141b..baa3a377 100644 --- a/doc/api.oa3 +++ b/doc/api.oa3 @@ -2439,15 +2439,7 @@ paths: content: application/json: schema: - type: object - required: - - type - - data - properties: - type: - type: string - data: - type: string + $ref: '#/components/schemas/Subject' /content/labels/{labelId}: put: @@ -2477,15 +2469,7 @@ paths: content: application/json: schema: - type: object - required: - - type - - data - properties: - type: - type: string - data: - type: string + $ref: '#/components/schemas/Subject' delete: tags: - content @@ -4405,17 +4389,24 @@ components: type: object required: - labelId - - labelRevision - - type - - data - - created - - modified + - revision + - labelData properties: labelId: type: string - labelRevision: - type: integer - format: int64 + revision: + type: string + labelData: + $ref: '#/components/schemas/LabelData' + + LabelData: + type: object + required: + - type + - data + - created + - updated + properties: type: type: string data: @@ -4423,6 +4414,9 @@ components: created: type: integer format: int64 + updated: + type: integer + format: int64 groups: # present only in account holder responses type: array items: diff --git a/net/server/internal/api_addLabel.go b/net/server/internal/api_addLabel.go new file mode 100644 index 00000000..7d1af9c1 --- /dev/null +++ b/net/server/internal/api_addLabel.go @@ -0,0 +1,71 @@ +package databag + +import ( + "net/http" + "errors" + "gorm.io/gorm" + "github.com/google/uuid" + "databag/internal/store" +) + +func AddLabel(w http.ResponseWriter, r *http.Request) { + + account, code, err := BearerAppToken(r, false) + if err != nil { + ErrResponse(w, code, err) + return + } + + var subject Subject + if err := ParseRequest(r, w, &subject); err != nil { + ErrResponse(w, http.StatusBadRequest, err) + return + } + + slot := &store.LabelSlot{} + label := &store.Label{} + err = store.DB.Transaction(func(tx *gorm.DB) error { + + data := &store.LabelData{ + Data: subject.Data, + } + if res := tx.Save(data).Error; res != nil { + return res + } + + label.LabelDataID = data.ID + label.LabelData = *data + label.DataType = subject.DataType + if res := tx.Save(label).Error; res != nil { + return res + } + + if res := tx.Where("account_id = ? AND label_id = 0", account.ID).First(slot).Error; res != nil { + if errors.Is(res, gorm.ErrRecordNotFound) { + slot.LabelSlotId = uuid.New().String() + slot.AccountID = account.ID + } else { + return res + } + } + slot.LabelID = label.ID + slot.Revision = account.LabelRevision + 1 + slot.Label = label + if res := tx.Save(slot).Error; res != nil { + return res + } + if res := tx.Model(&account).Update("label_revision", account.LabelRevision + 1).Error; res != nil { + return res + } + return nil + }) + if err != nil { + ErrResponse(w, http.StatusInternalServerError, err) + return + } + + SetStatus(account) + WriteResponse(w, getLabelModel(slot, true, true)) +} + + diff --git a/net/server/internal/api_content.go b/net/server/internal/api_content.go index a68015f6..21caf9cc 100644 --- a/net/server/internal/api_content.go +++ b/net/server/internal/api_content.go @@ -23,11 +23,6 @@ func AddArticleTag(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -func AddLabel(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=UTF-8") - w.WriteHeader(http.StatusOK) -} - func ClearArticleGroup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=UTF-8") w.WriteHeader(http.StatusOK) @@ -123,21 +118,11 @@ func SetArticleGroup(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -func SetArticleLabel(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=UTF-8") - w.WriteHeader(http.StatusOK) -} - func SetArticleSubject(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=UTF-8") w.WriteHeader(http.StatusOK) } -func SetLabelGroup(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=UTF-8") - w.WriteHeader(http.StatusOK) -} - func UpdateLabel(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=UTF-8") w.WriteHeader(http.StatusOK) diff --git a/net/server/internal/api_getArticles.go b/net/server/internal/api_getArticles.go index 9b04ad10..e902e7d7 100644 --- a/net/server/internal/api_getArticles.go +++ b/net/server/internal/api_getArticles.go @@ -113,11 +113,11 @@ func isShared(slot *store.ArticleSlot, guid string) bool { } func getAccountArticles(account *store.Account, revision int64, articles *[]store.ArticleSlot) error { - return store.DB.Preload("Article.Groups.GroupSlot").Preload("Article.Labels.Groups.GroupSlot").Where("account_id = ? AND revision > ?", account.ID, revision).Find(articles).Error + return store.DB.Preload("Article.Labels.LabelSlot").Preload("Article.Groups.GroupSlot").Preload("Article.Labels.Groups.GroupSlot").Where("account_id = ? AND revision > ?", account.ID, revision).Find(articles).Error } func getContactArticles(card *store.Card, revision int64, articles *[]store.ArticleSlot) error { - return store.DB.Preload("Article.Groups.Cards").Preload("Article.Labels.Groups.Cards").Where("account_id = ? AND revision > ?", card.Account.ID, revision).Find(articles).Error + return store.DB.Preload("Article.Labels.LabelSlot").Preload("Article.Groups.Cards").Preload("Article.Labels.Groups.Cards").Where("account_id = ? AND revision > ?", card.Account.ID, revision).Find(articles).Error } diff --git a/net/server/internal/api_removeArticle.go b/net/server/internal/api_removeArticle.go index 526f5c22..b2198b04 100644 --- a/net/server/internal/api_removeArticle.go +++ b/net/server/internal/api_removeArticle.go @@ -27,6 +27,12 @@ func RemoveArticle(w http.ResponseWriter, r *http.Request) { if slot.Article == nil { return nil } + if res := tx.Model(slot.Article).Association("Groups").Clear(); res != nil { + return res + } + if res := tx.Model(slot.Article).Association("Labels").Clear(); res != nil { + return res + } if res := tx.Delete(slot.Article).Error; res != nil { return res } diff --git a/net/server/internal/api_setArticleLabel.go b/net/server/internal/api_setArticleLabel.go new file mode 100644 index 00000000..382d8ee5 --- /dev/null +++ b/net/server/internal/api_setArticleLabel.go @@ -0,0 +1,76 @@ +package databag + +import ( + "errors" + "net/http" + "gorm.io/gorm" + "github.com/gorilla/mux" + "databag/internal/store" +) + +func SetArticleLabel(w http.ResponseWriter, r *http.Request) { + + account, code, err := BearerAppToken(r, false); + if err != nil { + ErrResponse(w, code, err) + return + } + + // scan parameters + params := mux.Vars(r) + articleId := params["articleId"] + labelId := params["labelId"] + + labelSlot := &store.LabelSlot{} + if err := store.DB.Preload("Label.LabelSlot").Where("account_id = ? AND label_slot_id = ?", account.ID, labelId).First(&labelSlot).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + ErrResponse(w, http.StatusInternalServerError, err) + } else { + ErrResponse(w, http.StatusNotFound, err) + } + return + } + if labelSlot.Label == nil { + ErrResponse(w, http.StatusNotFound, errors.New("referenced empty label slot")) + return + } + + articleSlot := &store.ArticleSlot{} + if err := store.DB.Preload("Article.Labels.LabelSlot").Preload("Article.Groups.GroupSlot").Where("account_id = ? AND article_slot_id = ?", account.ID, articleId).First(&articleSlot).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + ErrResponse(w, http.StatusInternalServerError, err) + } else { + ErrResponse(w, http.StatusNotFound, err) + } + return + } + if articleSlot.Article == nil { + ErrResponse(w, http.StatusNotFound, errors.New("referenced empty article slot")) + return + } + + err = store.DB.Transaction(func(tx *gorm.DB) error { + + if res := tx.Model(articleSlot.Article).Association("Labels").Append(labelSlot.Label); res != nil { + return res + } + + if res := tx.Model(articleSlot).Update("revision", account.ContentRevision + 1).Error; res != nil { + return res + } + + if res := tx.Model(account).Update("content_revision", account.ContentRevision + 1).Error; res != nil { + return res + } + + return nil + }) + if err != nil { + ErrResponse(w, http.StatusInternalServerError, err) + return + } + + SetContentNotification(account) + SetStatus(account) + WriteResponse(w, getArticleModel(articleSlot, false, true)) +} diff --git a/net/server/internal/api_setLabelGroup.go b/net/server/internal/api_setLabelGroup.go new file mode 100644 index 00000000..33f00e49 --- /dev/null +++ b/net/server/internal/api_setLabelGroup.go @@ -0,0 +1,82 @@ +package databag + +import ( + "errors" + "net/http" + "gorm.io/gorm" + "github.com/gorilla/mux" + "databag/internal/store" +) + +func SetLabelGroup(w http.ResponseWriter, r *http.Request) { + + account, code, err := BearerAppToken(r, false); + if err != nil { + ErrResponse(w, code, err) + return + } + + // scan parameters + params := mux.Vars(r) + groupId := params["groupId"] + labelId := params["labelId"] + + labelSlot := &store.LabelSlot{} + if err := store.DB.Preload("Label").Where("account_id = ? AND label_slot_id = ?", account.ID, labelId).First(&labelSlot).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + ErrResponse(w, http.StatusInternalServerError, err) + } else { + ErrResponse(w, http.StatusNotFound, err) + } + return + } + if labelSlot.Label == nil { + ErrResponse(w, http.StatusNotFound, errors.New("referenced empty label slot")) + return + } + + groupSlot := &store.GroupSlot{} + if err := store.DB.Preload("Group.GroupSlot").Where("account_id = ? AND group_slot_id = ?", account.ID, groupId).First(&groupSlot).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + ErrResponse(w, http.StatusInternalServerError, err) + } else { + ErrResponse(w, http.StatusNotFound, err) + } + return + } + if groupSlot.Group == nil { + ErrResponse(w, http.StatusNotFound, errors.New("referenced empty group slot")) + return + } + + err = store.DB.Transaction(func(tx *gorm.DB) error { + + if res := tx.Model(labelSlot.Label).Association("Groups").Append(groupSlot.Group); res != nil { + return res + } + + if res := tx.Model(labelSlot).Update("revision", account.LabelRevision + 1).Error; res != nil { + return res + } + + if res := tx.Model(account).Update("label_revision", account.LabelRevision + 1).Error; res != nil { + return res + } + + if res := tx.Model(account).Update("view_revision", account.ViewRevision + 1).Error; res != nil { + return res + } + + return nil + }) + if err != nil { + ErrResponse(w, http.StatusInternalServerError, err) + return + } + + SetViewNotification(account) + SetLabelNotification(account) + SetStatus(account) + WriteResponse(w, getLabelModel(labelSlot, true, true)) +} + diff --git a/net/server/internal/modelUtil.go b/net/server/internal/modelUtil.go index 2e53e008..d7d5bc34 100644 --- a/net/server/internal/modelUtil.go +++ b/net/server/internal/modelUtil.go @@ -9,6 +9,7 @@ func getCardModel(slot *store.CardSlot) *Card { if slot.Card == nil { return &Card{ CardId: slot.CardSlotId, + Revision: slot.Revision, } } @@ -61,6 +62,7 @@ func getGroupModel(slot *store.GroupSlot) *Group { if slot.Group == nil { return &Group{ GroupId: slot.GroupSlotId, + Revision: slot.Revision, } } @@ -76,6 +78,36 @@ func getGroupModel(slot *store.GroupSlot) *Group { } } +func getLabelModel(slot *store.LabelSlot, includeData bool, includeGroups bool) *Label { + + if !includeData || slot.Label == nil { + return &Label{ + LabelId: slot.LabelSlotId, + Revision: slot.Revision, + } + } + + var groups *[]string + if includeGroups { + groups = &[]string{} + for _, group := range slot.Label.Groups { + *groups = append(*groups, group.GroupSlot.GroupSlotId) + } + } + + return &Label{ + LabelId: slot.LabelSlotId, + Revision: slot.Revision, + LabelData: &LabelData{ + DataType: slot.Label.DataType, + Data: slot.Label.LabelData.Data, + Created: slot.Label.Created, + Updated: slot.Label.Updated, + Groups: groups, + }, + } +} + func getArticleModel(slot *store.ArticleSlot, contact bool, shared bool) *Article { if !shared || slot.Article == nil { @@ -85,14 +117,14 @@ func getArticleModel(slot *store.ArticleSlot, contact bool, shared bool) *Articl } } - var groups []string; + var groups []string if !contact { for _, group := range slot.Article.Groups { groups = append(groups, group.GroupSlot.GroupSlotId) } } - var labels []string; + var labels []string for _, label := range slot.Article.Labels { labels = append(labels, label.LabelSlot.LabelSlotId) } diff --git a/net/server/internal/models.go b/net/server/internal/models.go index 581e18c4..29f48cb2 100644 --- a/net/server/internal/models.go +++ b/net/server/internal/models.go @@ -174,7 +174,7 @@ type LabelData struct { Data string `json:"data"` Created int64 `json:"created"` Updated int64 `json:"updated"` - Groups []string `json:"groups,omitempty"` + Groups *[]string `json:"groups,omitempty"` } type NodeConfig struct { diff --git a/net/server/internal/store/schema.go b/net/server/internal/store/schema.go index f0083799..3904ed23 100644 --- a/net/server/internal/store/schema.go +++ b/net/server/internal/store/schema.go @@ -230,6 +230,7 @@ type Article struct { TagRevision int64 `gorm:"not null"` Groups []Group `gorm:"many2many:article_groups;"` Labels []Label `gorm:"many2many:article_labels;"` + ArticleSlot ArticleSlot } type ArticleAsset struct { diff --git a/net/server/internal/ucAddArticle_test.go b/net/server/internal/ucAddArticle_test.go index 83dfafb7..a2b70bd6 100644 --- a/net/server/internal/ucAddArticle_test.go +++ b/net/server/internal/ucAddArticle_test.go @@ -11,10 +11,16 @@ func TestAddArticle(t *testing.T) { var err error var rev *Revision var ver *Revision - var article Article + var article *Article var articles *[]Article var articleAccess *ArticleAccess var cards []Card + var label *Label + var subject *Subject + var vars *map[string]string + var contentRevision int64 + var viewRevision int64 + var labelRevision int64 // setup testing group set, err = AddTestGroup("addarticle") @@ -25,9 +31,11 @@ func TestAddArticle(t *testing.T) { // create article articleAccess = &ArticleAccess{ Groups: []string{set.A.B.GroupId} } - assert.NoError(t, SendEndpointTest(AddArticle, "POST", "/content/articles", nil, articleAccess, APP_TOKENAPP, set.A.Token, &article)) + article = &Article{} + assert.NoError(t, SendEndpointTest(AddArticle, "POST", "/content/articles", nil, articleAccess, APP_TOKENAPP, set.A.Token, article)) - assert.NoError(t, SendEndpointTest(AddArticle, "POST", "/content/articles", nil, articleAccess, APP_TOKENAPP, set.A.Token, &article)) + article = &Article{} + assert.NoError(t, SendEndpointTest(AddArticle, "POST", "/content/articles", nil, articleAccess, APP_TOKENAPP, set.A.Token, article)) assert.NoError(t, SendEndpointTest(RemoveArticle, "DELETE", "/content/articls/" + article.ArticleId, &map[string]string{"articleId": article.ArticleId }, nil, APP_TOKENAPP, set.A.Token, nil)) @@ -67,4 +75,70 @@ func TestAddArticle(t *testing.T) { assert.NoError(t, SendEndpointTest(GetArticles, "GET", "/content/articles?contentRevision=0&viewRevision=" + strconv.FormatInt(cards[0].CardData.NotifiedView, 10), nil, nil, APP_TOKENCONTACT, set.B.A.Token, articles)) assert.Equal(t, 2, len(*articles)) + ver = GetTestRevision(set.C.Revisions) + + // add another article + article = &Article{} + articleAccess = &ArticleAccess{} + assert.NoError(t, SendEndpointTest(AddArticle, "POST", "/content/articles", nil, articleAccess, APP_TOKENAPP, set.A.Token, article)) + + // capture updated card on new article + rev = GetTestRevision(set.C.Revisions) + assert.NoError(t, SendEndpointTest(GetCards, "GET", "/contact/cards?cardRevision=" + strconv.FormatInt(ver.Card, 10), nil, nil, APP_TOKENAPP, set.C.Token, &cards)) + assert.Equal(t, 1, len(cards)) + viewRevision = cards[0].CardData.NotifiedView + contentRevision = cards[0].CardData.NotifiedContent + labelRevision = cards[0].CardData.NotifiedLabel + ver = rev + + // create new label + label = &Label{} + subject = &Subject{ DataType: "labeltype", Data: "labeldata" } + assert.NoError(t, SendEndpointTest(AddLabel, "POST", "/content/labels", nil, subject, APP_TOKENAPP, set.A.Token, label)) + vars = &map[string]string{ + "labelId": label.LabelId, + "groupId": set.A.C.GroupId, + } + label = &Label{} + assert.NoError(t, SendEndpointTest(SetLabelGroup, "POST", "/content/labels/{labelId}/groups/{groupId}", vars, nil, APP_TOKENAPP, set.A.Token, label)) + + // capture updated card on new assigned label + rev = GetTestRevision(set.C.Revisions) + assert.NoError(t, SendEndpointTest(GetCards, "GET", "/contact/cards?cardRevision=" + strconv.FormatInt(ver.Card, 10), nil, nil, APP_TOKENAPP, set.C.Token, &cards)) + assert.Equal(t, 1, len(cards)) + assert.NotEqual(t, viewRevision, cards[0].CardData.NotifiedView) + assert.NotEqual(t, labelRevision, cards[0].CardData.NotifiedLabel) + viewRevision = cards[0].CardData.NotifiedView + contentRevision = cards[0].CardData.NotifiedContent + labelRevision = cards[0].CardData.NotifiedLabel + ver = rev + + // assign label to article + vars = &map[string]string{ + "labelId": label.LabelId, + "articleId": article.ArticleId, + } + article = &Article{} + assert.NoError(t, SendEndpointTest(SetArticleLabel, "POST", "/content/articles/{articleId}/labels/{labelId}", vars, nil, APP_TOKENAPP, set.A.Token, article)) + + // capture updated card on assigned article + rev = GetTestRevision(set.C.Revisions) + assert.NoError(t, SendEndpointTest(GetCards, "GET", "/contact/cards?cardRevision=" + strconv.FormatInt(ver.Card, 10), nil, nil, APP_TOKENAPP, set.C.Token, &cards)) + assert.Equal(t, 1, len(cards)) + assert.NotEqual(t, contentRevision, cards[0].CardData.NotifiedContent) + ver = rev + + // confirm c can see new article + articles = &[]Article{} + assert.NoError(t, SendEndpointTest(GetArticles, "GET", "/content/articles", nil, nil, APP_TOKENCONTACT, set.C.A.Token, articles)) + assert.Equal(t, 1, len(*articles)) + assert.Equal(t, (*articles)[0].ArticleId, article.ArticleId) + assert.Equal(t, 1, len((*articles)[0].ArticleData.Labels)) + assert.Equal(t, (*articles)[0].ArticleData.Labels[0], label.LabelId) + + // confirm b cannot see new article + articles = &[]Article{} + assert.NoError(t, SendEndpointTest(GetArticles, "GET", "/content/articles", nil, nil, APP_TOKENCONTACT, set.B.A.Token, articles)) + assert.Equal(t, 1, len(*articles)) + assert.NotEqual(t, article.ArticleId, (*articles)[0].ArticleId) }