diff --git a/database/mongodb/db.go b/database/mongodb/db.go index e8b6bbe..f949fd0 100644 --- a/database/mongodb/db.go +++ b/database/mongodb/db.go @@ -21,6 +21,7 @@ type MongoDB struct { logs *logRepository posts *postRepository files *fileRepository + story repositories.StoryRepository } func (m *MongoDB) Changes() repositories.ChangeRepository { @@ -51,6 +52,10 @@ func (m *MongoDB) Files() repositories.FileRepository { return m.files } +func (m *MongoDB) Story() repositories.StoryRepository { + return m.story +} + func (m *MongoDB) Close(ctx context.Context) error { m.session.Close() return nil @@ -114,6 +119,12 @@ func Init(cfg config.Database) (*MongoDB, error) { return nil, err } + story, err := newStoryRepository(db) + if err != nil { + session.Close() + return nil, err + } + go posts.fixPositions(logs) return &MongoDB{ @@ -123,6 +134,7 @@ func Init(cfg config.Database) (*MongoDB, error) { characters: characters, channels: channels, tags: newTagRepository(db), + story: story, logs: logs, posts: posts, files: files, diff --git a/database/mongodb/stories.go b/database/mongodb/stories.go new file mode 100644 index 0000000..9c23069 --- /dev/null +++ b/database/mongodb/stories.go @@ -0,0 +1,208 @@ +package mongodb + +import ( + "context" + "git.aiterp.net/rpdata/api/internal/generate" + "git.aiterp.net/rpdata/api/models" + "git.aiterp.net/rpdata/api/repositories" + "github.com/globalsign/mgo" + "github.com/globalsign/mgo/bson" + "log" +) + +type storyRepository struct { + stories *mgo.Collection + chapters *mgo.Collection + comments *mgo.Collection +} + +func newStoryRepository(db *mgo.Database) (repositories.StoryRepository, error) { + collection := db.C("story.stories") + + err := collection.EnsureIndexKey("tags") + if err != nil { + return nil, err + } + err = collection.EnsureIndexKey("author") + if err != nil { + return nil, err + } + err = collection.EnsureIndexKey("updatedDate") + if err != nil { + return nil, err + } + err = collection.EnsureIndexKey("fictionalDate") + if err != nil { + return nil, err + } + err = collection.EnsureIndexKey("listed") + if err != nil { + return nil, err + } + + return &storyRepository{ + stories: collection, + chapters: db.C("story.chapters"), + comments: db.C("story.comments"), + }, nil +} + +func (r *storyRepository) Find(ctx context.Context, id string) (*models.Story, error) { + story := new(models.Story) + err := r.stories.FindId(id).One(story) + if err != nil { + return nil, err + } + + return story, nil +} + +func (r *storyRepository) List(ctx context.Context, filter models.StoryFilter) ([]*models.Story, error) { + query := bson.M{} + if filter.Author != nil { + query["author"] = *filter.Author + } + if filter.Category != nil { + query["category"] = *filter.Category + } + if filter.Open != nil { + query["open"] = *filter.Open + } + if len(filter.Tags) > 0 { + query["tags"] = bson.M{"$all": filter.Tags} + } + if filter.Unlisted != nil { + query["listed"] = *filter.Unlisted + } + if !filter.EarliestFictionalDate.IsZero() && !filter.LatestFictionalDate.IsZero() { + query["fictionalDate"] = bson.M{ + "$gte": filter.EarliestFictionalDate, + "$lte": filter.LatestFictionalDate, + } + } else if !filter.EarliestFictionalDate.IsZero() { + query["fictionalDate"] = bson.M{ + "$gte": filter.EarliestFictionalDate, + } + } else if !filter.LatestFictionalDate.IsZero() { + query["fictionalDate"] = bson.M{ + "$lte": filter.LatestFictionalDate, + } + } + + stories := make([]*models.Story, 0, 32) + err := r.stories.Find(query).Sort("-updatedDate ").Limit(filter.Limit).All(&stories) + if err != nil { + if err == mgo.ErrNotFound { + return stories, nil + } + + return nil, err + } + + return stories, nil +} + +func (r *storyRepository) Insert(ctx context.Context, story models.Story) (*models.Story, error) { + story.ID = generate.StoryID() + + err := r.stories.Insert(story) + if err != nil { + return nil, err + } + + return &story, nil +} + +func (r *storyRepository) Update(ctx context.Context, story models.Story, update models.StoryUpdate) (*models.Story, error) { + updateBson := bson.M{} + if update.Name != nil { + updateBson["name"] = *update.Name + story.Name = *update.Name + } + if update.Open != nil { + updateBson["open"] = *update.Open + story.Open = *update.Open + } + if update.Category != nil { + updateBson["category"] = *update.Category + story.Category = *update.Category + } + if update.Author != nil { + updateBson["author"] = *update.Author + story.Author = *update.Author + } + if update.FictionalDate != nil { + updateBson["fictionalDate"] = *update.FictionalDate + story.FictionalDate = *update.FictionalDate + } + if update.UpdatedDate != nil { + updateBson["updatedDate"] = *update.UpdatedDate + story.UpdatedDate = *update.UpdatedDate + } + if update.Listed != nil { + updateBson["listed"] = *update.Listed + story.Listed = *update.Listed + } + + err := r.stories.UpdateId(story.ID, bson.M{"$set": updateBson}) + if err != nil { + return nil, err + } + + return &story, nil +} + +func (r *storyRepository) AddTag(ctx context.Context, story models.Story, tag models.Tag) error { + ci, err := r.stories.UpdateAll(bson.M{"_id": story.ID}, bson.M{"$addToSet": bson.M{"tags": tag}}) + if err != nil { + return err + } + if ci.Updated == 0 { + return repositories.ErrTagExists + } + + return nil +} + +func (r *storyRepository) RemoveTag(ctx context.Context, story models.Story, tag models.Tag) error { + ci, err := r.stories.UpdateAll(bson.M{"_id": story.ID}, bson.M{"$pull": bson.M{"tags": tag}}) + if err != nil { + return err + } + if ci.Updated == 0 { + return repositories.ErrTagDoesNotExist + } + + return nil +} + +func (r *storyRepository) Delete(ctx context.Context, story models.Story) error { + err := r.stories.RemoveId(story.ID) + if err != nil { + return err + } + + chapterIds := make([]string, 0, 8) + err = r.chapters.Find(bson.M{"storyId": story.ID}).Distinct("_id", &chapterIds) + if err != nil { + log.Println("Failed to find chapterIds:", err) + return nil + } + if len(chapterIds) > 0 { + c1, err := r.chapters.RemoveAll(bson.M{"storyId": story.ID}) + if err != nil { + log.Println("Failed to remove chapters:", err) + return nil + } + + c2, err := r.chapters.RemoveAll(bson.M{"comments": bson.M{"$in": chapterIds}}) + if err != nil { + log.Println("Failed to remove comments:", err) + return nil + } + + log.Printf("Removed story %s (%d chapters, %d comments)", story.ID, c1.Removed, c2.Removed) + } + + return nil +} diff --git a/internal/generate/id.go b/internal/generate/id.go index 58bc30e..3235791 100644 --- a/internal/generate/id.go +++ b/internal/generate/id.go @@ -53,6 +53,11 @@ func ChapterID() string { return ID("SC", 24) } +// CommentID generates a post ID. +func CommentID() string { + return ID("SCC", 32) +} + // FileID generates a file ID. func FileID() string { return ID("F", 16) diff --git a/models/chapter.go b/models/chapter.go index 23698bd..317318f 100644 --- a/models/chapter.go +++ b/models/chapter.go @@ -25,4 +25,17 @@ func (chapter *Chapter) CanComment() bool { // ChangeObject in GQL. func (*Chapter) IsChangeObject() { panic("this method is a dummy, and so is its caller") -} \ No newline at end of file +} + +type ChapterFilter struct { + StoryID *string + Limit int +} + +type ChapterUpdate struct { + Title *string + Source *string + FictionalDate *time.Time + CommentMode *ChapterCommentMode + CommentsLocked *bool +} diff --git a/models/comment.go b/models/comment.go index 2634ad7..a1a083d 100644 --- a/models/comment.go +++ b/models/comment.go @@ -12,7 +12,7 @@ type Comment struct { CharacterID string `bson:"characterId"` FictionalDate time.Time `bson:"fictionalDate"` CreatedDate time.Time `bson:"createdDate"` - EditedDate time.Time `bson:"editeddDate"` + EditedDate time.Time `bson:"editedDate"` Source string `bson:"source"` } @@ -20,4 +20,17 @@ type Comment struct { // ChangeObject in GQL. func (*Comment) IsChangeObject() { panic("this method is a dummy, and so is its caller") -} \ No newline at end of file +} + +type CommentFilter struct { + ChapterID *string + Limit int +} + +type CommentUpdate struct { + Source *string + CharacterName *string + CharacterID *string + Subject *string + FictionalDate *time.Time +} diff --git a/models/story.go b/models/story.go index 3a1d02b..439b04b 100644 --- a/models/story.go +++ b/models/story.go @@ -21,4 +21,25 @@ type Story struct { // ChangeObject in GQL. func (*Story) IsChangeObject() { panic("this method is a dummy, and so is its caller") -} \ No newline at end of file +} + +type StoryFilter struct { + Author *string + Tags []Tag + EarliestFictionalDate time.Time + LatestFictionalDate time.Time + Category *StoryCategory + Open *bool + Unlisted *bool + Limit int +} + +type StoryUpdate struct { + Name *string + Category *StoryCategory + Author *string + Open *bool + Listed *bool + FictionalDate *time.Time + UpdatedDate *time.Time +} diff --git a/repositories/chapter.go b/repositories/chapter.go new file mode 100644 index 0000000..f10f254 --- /dev/null +++ b/repositories/chapter.go @@ -0,0 +1,14 @@ +package repositories + +import ( + "context" + "git.aiterp.net/rpdata/api/models" +) + +type ChapterRepository interface { + Find(ctx context.Context, id string) (*models.Chapter, error) + List(ctx context.Context, filter models.ChapterFilter) ([]*models.Chapter, error) + Insert(ctx context.Context, chapter models.Chapter) (*models.Chapter, error) + Update(ctx context.Context, chapter models.Chapter, update models.ChapterUpdate) (*models.Story, error) + Delete(ctx context.Context, chapter models.Chapter) error +} diff --git a/repositories/comment.go b/repositories/comment.go new file mode 100644 index 0000000..bc4aa26 --- /dev/null +++ b/repositories/comment.go @@ -0,0 +1,14 @@ +package repositories + +import ( + "context" + "git.aiterp.net/rpdata/api/models" +) + +type CommentRepository interface { + Find(ctx context.Context, id string) (*models.Comment, error) + List(ctx context.Context, filter models.CommentFilter) ([]*models.Comment, error) + Insert(ctx context.Context, comment models.Comment) (*models.Comment, error) + Update(ctx context.Context, comment models.Comment, update models.CommentUpdate) (*models.Story, error) + Delete(ctx context.Context, comment models.Comment) error +} diff --git a/repositories/repository.go b/repositories/repository.go index 2ea4ba2..0e9269b 100644 --- a/repositories/repository.go +++ b/repositories/repository.go @@ -10,3 +10,9 @@ var ErrInvalidPosition = errors.New("position is out of bounds") // ErrParentMismatch is returned if the list refer to different parent objects when it shouldn't. var ErrParentMismatch = errors.New("parent resource is not the same for the entire list") + +// ErrTagExists is returned if an existing tag is added again. +var ErrTagExists = errors.New("tag already exists") + +// ErrTagDoesNotExist is returned if a non-existing tag is attempted removed. +var ErrTagDoesNotExist = errors.New("tag already exists") diff --git a/repositories/story.go b/repositories/story.go new file mode 100644 index 0000000..0c07c1d --- /dev/null +++ b/repositories/story.go @@ -0,0 +1,16 @@ +package repositories + +import ( + "context" + "git.aiterp.net/rpdata/api/models" +) + +type StoryRepository interface { + Find(ctx context.Context, id string) (*models.Story, error) + List(ctx context.Context, filter models.StoryFilter) ([]*models.Story, error) + Insert(ctx context.Context, story models.Story) (*models.Story, error) + Update(ctx context.Context, story models.Story, update models.StoryUpdate) (*models.Story, error) + AddTag(ctx context.Context, story models.Story, tag models.Tag) error + RemoveTag(ctx context.Context, story models.Story, tag models.Tag) error + Delete(ctx context.Context, story models.Story) error +} diff --git a/services/changes.go b/services/changes.go index ed44d72..83c9186 100644 --- a/services/changes.go +++ b/services/changes.go @@ -45,6 +45,21 @@ func (s *ChangeService) Submit(ctx context.Context, model models.ChangeModel, op Date: time.Now(), } + // Do not allow * keys in unlisted objects, but allow laziness on the + // call site to not bloat the code too much. + if !listed { + deleteList := make([]int, 0, 2) + for i, key := range keys { + if key.ID == "*" { + deleteList = append(deleteList, i-len(deleteList)) + } + } + + for _, index := range deleteList { + change.Keys = append(change.Keys[:index], change.Keys[index+1:]...) + } + } + for _, obj := range objects { if !change.AddObject(obj) { log.Printf("Cannot add object of type %T to change", obj) diff --git a/services/stories.go b/services/stories.go new file mode 100644 index 0000000..405d9aa --- /dev/null +++ b/services/stories.go @@ -0,0 +1,152 @@ +package services + +import ( + "context" + "errors" + "git.aiterp.net/rpdata/api/internal/auth" + "git.aiterp.net/rpdata/api/internal/generate" + "git.aiterp.net/rpdata/api/models" + "git.aiterp.net/rpdata/api/models/changekeys" + "git.aiterp.net/rpdata/api/repositories" + "time" +) + +// StoryService is a service governing all operations on stories and child objects. +type StoryService struct { + stories repositories.StoryRepository + chapters repositories.ChapterRepository + comments repositories.CommentRepository + changeService *ChangeService +} + +func (s *StoryService) FindStory(ctx context.Context, id string) (*models.Story, error) { + return s.stories.Find(ctx, id) +} + +func (s *StoryService) ListStories(ctx context.Context, filter models.StoryFilter) ([]*models.Story, error) { + return s.stories.List(ctx, filter) +} + +func (s *StoryService) ListChapters(ctx context.Context, story models.Story) ([]*models.Chapter, error) { + return s.chapters.List(ctx, models.ChapterFilter{StoryID: &story.ID, Limit: 0}) +} + +func (s *StoryService) ListComments(ctx context.Context, chapter models.Chapter, limit int) ([]*models.Comment, error) { + return s.comments.List(ctx, models.CommentFilter{ChapterID: &chapter.ID, Limit: limit}) +} + +func (s *StoryService) CreateStory(ctx context.Context, name, author string, category models.StoryCategory, listed, open bool, tags []models.Tag, createdDate, fictionalDate time.Time) (*models.Story, error) { + story := &models.Story{ + Name: name, + Author: author, + Category: category, + Listed: listed, + Open: open, + Tags: tags, + CreatedDate: createdDate, + FictionalDate: fictionalDate, + UpdatedDate: createdDate, + } + + if err := auth.CheckPermission(ctx, "add", story); err != nil { + return nil, err + } + + story, err := s.stories.Insert(ctx, *story) + if err != nil { + return nil, err + } + + s.changeService.Submit(ctx, "Story", "add", story.Listed, changekeys.Listed(story), story) + + return story, nil +} + +func (s *StoryService) CreateChapter(ctx context.Context, story models.Story, title, author, source string, createdDate time.Time, fictionalDate *time.Time, commentMode models.ChapterCommentMode) (*models.Chapter, error) { + chapter := &models.Chapter{ + ID: generate.ChapterID(), + StoryID: story.ID, + Title: title, + Author: author, + Source: source, + CreatedDate: createdDate, + EditedDate: createdDate, + CommentMode: commentMode, + CommentsLocked: false, + } + if fictionalDate != nil { + chapter.FictionalDate = *fictionalDate + } + + if story.Open { + if !auth.TokenFromContext(ctx).Permitted("member", "chapter.add") { + return nil, auth.ErrUnauthorized + } + } else { + if err := auth.CheckPermission(ctx, "add", chapter); err != nil { + return nil, err + } + } + + chapter, err := s.chapters.Insert(ctx, *chapter) + if err != nil { + return nil, err + } + + if createdDate.After(story.UpdatedDate) { + _, _ = s.stories.Update(ctx, story, models.StoryUpdate{UpdatedDate: &createdDate}) + } + + s.changeService.Submit(ctx, "Chapter", "add", story.Listed, changekeys.Many(story, chapter), chapter) + + return chapter, nil +} + +// CreateComment adds a comment. +func (s *StoryService) CreateComment(ctx context.Context, chapter models.Chapter, subject, author, source, characterName string, character *models.Character, createdDate time.Time, fictionalDate time.Time) (*models.Comment, error) { + characterID := "" + if character != nil { + characterID = character.ID + } + + if !chapter.CanComment() { + return nil, errors.New("comments are locked or disabled") + } + + if author == "" { + if token := auth.TokenFromContext(ctx); token != nil { + author = token.UserID + } else { + return nil, auth.ErrUnauthenticated + } + } + + comment := &models.Comment{ + ID: generate.CommentID(), + ChapterID: chapter.ID, + Subject: subject, + Author: author, + CharacterName: characterName, + CharacterID: characterID, + FictionalDate: fictionalDate, + CreatedDate: createdDate, + EditedDate: createdDate, + Source: source, + } + if err := auth.CheckPermission(ctx, "add", comment); err != nil { + return nil, err + } + + comment, err := s.comments.Insert(ctx, *comment) + if err != nil { + return nil, err + } + + if story, err := s.stories.Find(ctx, chapter.StoryID); err == nil { + s.changeService.Submit(ctx, "Comment", "add", story.Listed, changekeys.Many(story, chapter, comment), comment) + } else { + s.changeService.Submit(ctx, "Comment", "add", false, changekeys.Many(chapter, comment), comment) + } + + return comment, nil +}