From 389ea6258df83a1812d834e9860cefd950fb536f Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 11 Mar 2024 21:43:43 +0100 Subject: [PATCH] feat(reactions): add reactions struct and routes --- docs/content/doc/usage/errors.md | 1 + pkg/db/db.go | 10 +++ pkg/migration/20240311173251.go | 49 +++++++++++++ pkg/models/error.go | 27 +++++++ pkg/models/project.go | 6 +- pkg/models/reaction.go | 117 +++++++++++++++++++++++++++++++ pkg/models/reaction_rights.go | 81 +++++++++++++++++++++ pkg/routes/routes.go | 9 +++ pkg/user/user.go | 11 ++- 9 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 pkg/migration/20240311173251.go create mode 100644 pkg/models/reaction.go create mode 100644 pkg/models/reaction_rights.go diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index fc364ee7e..4aff46443 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -97,6 +97,7 @@ This document describes the different errors Vikunja can return. | 4022 | 400 | The task has a relative reminder which does not specify relative to what. | | 4023 | 409 | Tried to create a task relation which would create a cycle. | | 4024 | 400 | The provided filter expression is invalid. | +| 4025 | 400 | The reaction kind is invalid. | ## Team diff --git a/pkg/db/db.go b/pkg/db/db.go index 1570bdfc9..6e94910bb 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -23,6 +23,7 @@ import ( "strconv" "strings" "time" + "xorm.io/builder" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" @@ -227,3 +228,12 @@ func NewSession() *xorm.Session { func Type() schemas.DBType { return x.Dialect().URI().DBType } + +func GetDialect() string { + dialect := config.DatabaseType.GetString() + if dialect == "sqlite" { + dialect = builder.SQLITE + } + + return dialect +} diff --git a/pkg/migration/20240311173251.go b/pkg/migration/20240311173251.go new file mode 100644 index 000000000..5bd3b13e6 --- /dev/null +++ b/pkg/migration/20240311173251.go @@ -0,0 +1,49 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "time" + "xorm.io/xorm" +) + +type reactions20240311173251 struct { + ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"reaction"` + UserID int64 `xorm:"bigint not null INDEX" json:"-"` + EntityID int64 `xorm:"bigint not null INDEX" json:"entity_id"` + EntityKind int `xorm:"bigint not null INDEX" json:"entity_kind"` + Value string `xorm:"varchar(20) not null INDEX" json:"value"` + Created time.Time `xorm:"created not null" json:"created"` +} + +func (reactions20240311173251) TableName() string { + return "reactions" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240311173251", + Description: "Create reactions table", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync2(reactions20240311173251{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/error.go b/pkg/models/error.go index 431d9e7d5..8eb151830 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1060,6 +1060,33 @@ func (err ErrInvalidFilterExpression) HTTPError() web.HTTPError { } } +// ErrInvalidReactionEntityKind represents an error where the reaction kind is invalid +type ErrInvalidReactionEntityKind struct { + Kind string +} + +// IsErrInvalidReactionEntityKind checks if an error is ErrInvalidReactionEntityKind. +func IsErrInvalidReactionEntityKind(err error) bool { + _, ok := err.(ErrInvalidReactionEntityKind) + return ok +} + +func (err ErrInvalidReactionEntityKind) Error() string { + return fmt.Sprintf("Reaction kind %s is invalid", err.Kind) +} + +// ErrCodeInvalidReactionEntityKind holds the unique world-error code of this error +const ErrCodeInvalidReactionEntityKind = 4025 + +// HTTPError holds the http error description +func (err ErrInvalidReactionEntityKind) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeInvalidReactionEntityKind, + Message: fmt.Sprintf("The reaction kind '%s' is invalid.", err.Kind), + } +} + // ============ // Team errors // ============ diff --git a/pkg/models/project.go b/pkg/models/project.go index 7958cbef5..7be2d933c 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -23,7 +23,6 @@ import ( "strings" "time" - "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/files" @@ -370,10 +369,7 @@ type projectOptions struct { } func getUserProjectsStatement(parentProjectIDs []int64, userID int64, search string, getArchived bool) *builder.Builder { - dialect := config.DatabaseType.GetString() - if dialect == "sqlite" { - dialect = builder.SQLITE - } + dialect := db.GetDialect() // Adding a 1=1 condition by default here because xorm always needs a condition and cannot handle nil conditions var getArchivedCond builder.Cond = builder.Eq{"1": 1} diff --git a/pkg/models/reaction.go b/pkg/models/reaction.go new file mode 100644 index 000000000..9019349de --- /dev/null +++ b/pkg/models/reaction.go @@ -0,0 +1,117 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/web" + "time" + "xorm.io/builder" + "xorm.io/xorm" +) + +type ReactionKind int + +const ( + ReactionKindTask = iota + ReactionKindComment +) + +type Reaction struct { + // The unique numeric id of this reaction + ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"reaction"` + + // The user who reacted + User *user.User `xorm:"-" json:"user" valid:"-"` + UserID int64 `xorm:"bigint not null INDEX" json:"-"` + + // The id of the entity you're reacting to + EntityID int64 `xorm:"bigint not null INDEX" json:"entity_id" param:"entityid"` + // The entity kind which you're reacting to. Can be 0 for task, 1 for comment. + EntityKind ReactionKind `xorm:"bigint not null INDEX" json:"entity_kind"` + EntityKindString string `xorm:"-" json:"-" param:"entitykind"` + + // The actual reaction. This can be any valid utf character or text, up to a length of 20. + Value string `xorm:"varchar(20) not null INDEX" json:"value"` + + // A timestamp when this reaction was created. You cannot change this value. + Created time.Time `xorm:"created not null" json:"created"` + + web.CRUDable `xorm:"-" json:"-"` + web.Rights `xorm:"-" json:"-"` +} + +func (*Reaction) TableName() string { + return "reactions" +} + +func (r *Reaction) ReadAll(s *xorm.Session, _ web.Auth, _ string, _ int, _ int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { + reactions := []*Reaction{} + err = s.Where("entity_id = ? AND entity_kind = ?", r.EntityID, r.EntityKind).Find(&reactions) + if err != nil { + return + } + + cond := builder. + Select("user_id"). + From("reactions"). + And(builder.Eq{"entity_id": r.EntityID}). + And(builder.Eq{"entity_kind": r.EntityKind}) + + users, err := user.GetUsersByCond(s, cond) + if err != nil { + return + } + + reactionMap := make(map[string][]*user.User) + for _, reaction := range reactions { + if _, has := reactionMap[reaction.Value]; !has { + reactionMap[reaction.Value] = []*user.User{} + } + + reactionMap[reaction.Value] = append(reactionMap[reaction.Value], users[reaction.UserID]) + } + + return reactionMap, len(reactionMap), int64(len(reactions)), nil +} + +func (r *Reaction) Delete(s *xorm.Session, a web.Auth) (err error) { + r.UserID = a.GetID() + + _, err = s.Where("user_id = ? AND entity_id = ? AND entity_kind = ?", r.UserID, r.EntityID, r.EntityKind). + Delete(&Reaction{}) + return +} + +func (r *Reaction) Create(s *xorm.Session, a web.Auth) (err error) { + r.UserID = a.GetID() + + exists, err := s.Where("user_id = ? AND entity_id = ? AND entity_kind = ?", r.UserID, r.EntityID, r.EntityKind). + Exist(&Reaction{}) + if err != nil { + return err + } + + if exists { + _, err = s.Where("user_id = ? AND entity_id = ? AND entity_kind = ?", r.UserID, r.EntityID, r.EntityKind). + Delete(&Reaction{}) + return + } + + _, err = s.Insert(r) + return +} diff --git a/pkg/models/reaction_rights.go b/pkg/models/reaction_rights.go new file mode 100644 index 000000000..ad6321e52 --- /dev/null +++ b/pkg/models/reaction_rights.go @@ -0,0 +1,81 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/web" + "xorm.io/xorm" +) + +func (r *Reaction) setEntityKindFromString() (err error) { + switch r.EntityKindString { + case "tasks": + r.EntityKind = ReactionKindTask + return + case "comments": + r.EntityKind = ReactionKindComment + return + } + + return ErrInvalidReactionEntityKind{ + Kind: r.EntityKindString, + } +} + +func (r *Reaction) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + t, err := r.getTask(s) + if err != nil { + return false, 0, err + } + return t.CanRead(s, a) +} + +func (r *Reaction) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { + t, err := r.getTask(s) + if err != nil { + return false, err + } + return t.CanUpdate(s, a) +} + +func (r *Reaction) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { + t, err := r.getTask(s) + if err != nil { + return false, err + } + return t.CanUpdate(s, a) +} + +func (r *Reaction) getTask(s *xorm.Session) (t *Task, err error) { + err = r.setEntityKindFromString() + if err != nil { + return + } + + t = &Task{ID: r.EntityID} + + if r.EntityKind == ReactionKindComment { + tc := &TaskComment{TaskID: r.EntityID} + err = getTaskCommentSimple(s, tc) + if err != nil { + return + } + t.ID = tc.TaskID + } + + return +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 42934d2bf..e9c9e90a3 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -589,6 +589,15 @@ func registerAPIRoutes(a *echo.Group) { a.POST("/projects/:project/webhooks/:webhook", webhookProvider.UpdateWeb) a.GET("/webhooks/events", apiv1.GetAvailableWebhookEvents) } + + reactionProvider := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.Reaction{} + }, + } + a.GET("/:entitykind/:entityid/reactions", reactionProvider.ReadAllWeb) + a.DELETE("/:entitykind/:entityid/reactions", reactionProvider.DeleteWeb) + a.PUT("/:entitykind/:entityid/reactions", reactionProvider.CreateWeb) } func registerMigrations(m *echo.Group) { diff --git a/pkg/user/user.go b/pkg/user/user.go index 43dc339c5..17b52aef8 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -23,6 +23,7 @@ import ( "reflect" "strconv" "time" + "xorm.io/builder" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" @@ -259,13 +260,17 @@ func GetUserWithEmail(s *xorm.Session, user *User) (userOut *User, err error) { // GetUsersByIDs returns a map of users from a slice of user ids func GetUsersByIDs(s *xorm.Session, userIDs []int64) (users map[int64]*User, err error) { - users = make(map[int64]*User) - if len(userIDs) == 0 { return users, nil } - err = s.In("id", userIDs).Find(&users) + return GetUsersByCond(s, builder.Dialect(db.GetDialect()).Where(builder.In("id", userIDs))) +} + +func GetUsersByCond(s *xorm.Session, cond *builder.Builder) (users map[int64]*User, err error) { + users = make(map[int64]*User) + + err = s.Where(cond).Find(&users) if err != nil { return }